From 04e1f2140599490ec88252dbdc2d5ca4dff10116 Mon Sep 17 00:00:00 2001 From: agent-company Date: Thu, 26 Mar 2026 10:05:17 +0000 Subject: [PATCH] feat: implement issue and PR detail handlers with routes Add GET /issues/{owner}/{repo}/{index} and GET /pulls/{owner}/{repo}/{index} routes that fetch individual issues/PRs from the Gitea API and render them using the existing issue_detail.html and pull_detail.html templates. New client methods: - GetIssue: fetch a single issue by owner/repo/index - GetPull: fetch a single pull request by owner/repo/index - GetIssueComments: fetch comments for an issue - GetRepoLabels: fetch available labels for a repository Both handlers support HTMX fragment responses and full-page rendering, consistent with the existing handler pattern. Closes leeworks-agents/gitea-mobile#24 Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/gitea/client.go | 97 ++++++++++++++++++++++++++++ internal/handlers/handlers.go | 115 ++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) diff --git a/internal/gitea/client.go b/internal/gitea/client.go index c56c2af..9e98b7f 100644 --- a/internal/gitea/client.go +++ b/internal/gitea/client.go @@ -530,6 +530,103 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string return queue, nil } +// Comment represents a comment on an issue or pull request. +type Comment struct { + ID int64 `json:"id"` + Body string `json:"body"` + User string `json:"-"` // populated from nested object + CreatedAt string `json:"-"` // formatted after fetch + RawUser struct { + Login string `json:"login"` + } `json:"user"` + RawCreatedAt time.Time `json:"created_at"` +} + +// Label represents a Gitea label (used for available labels list). +type Label struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +// GetIssue fetches a single issue by owner, repo, and index. +func (c *Client) GetIssue(ctx context.Context, token, owner, repo string, index int64) (*Issue, error) { + path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index) + resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("fetching issue: %w", err) + } + defer resp.Body.Close() + + var issue Issue + if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { + return nil, fmt.Errorf("decoding issue: %w", err) + } + + issue.RepoOwner = owner + issue.RepoName = repo + return &issue, nil +} + +// GetPull fetches a single pull request by owner, repo, and index. +func (c *Client) GetPull(ctx context.Context, token, owner, repo string, index int64) (*PullRequest, error) { + path := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index) + resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("fetching pull request: %w", err) + } + defer resp.Body.Close() + + var pr PullRequest + if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { + return nil, fmt.Errorf("decoding pull request: %w", err) + } + + pr.RepoOwner = owner + pr.RepoName = repo + return &pr, nil +} + +// GetIssueComments fetches comments for an issue or pull request. +func (c *Client) GetIssueComments(ctx context.Context, token, owner, repo string, index int64) ([]Comment, error) { + path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments?limit=50", owner, repo, index) + resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("fetching comments: %w", err) + } + defer resp.Body.Close() + + var comments []Comment + if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { + return nil, fmt.Errorf("decoding comments: %w", err) + } + + // Populate convenience fields. + for i := range comments { + comments[i].User = comments[i].RawUser.Login + comments[i].CreatedAt = comments[i].RawCreatedAt.Format("Jan 2, 2006 15:04") + } + + return comments, nil +} + +// GetRepoLabels fetches all labels for a repository. +func (c *Client) GetRepoLabels(ctx context.Context, token, owner, repo string) ([]Label, error) { + path := fmt.Sprintf("/repos/%s/%s/labels?limit=50", owner, repo) + resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("fetching labels: %w", err) + } + defer resp.Body.Close() + + var labels []Label + if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil { + return nil, fmt.Errorf("decoding labels: %w", err) + } + + return labels, nil +} + // CreateIssue creates a new issue in the specified repository. func (c *Client) CreateIssue(ctx context.Context, token, owner, repo, title, body string, labels []int64) (*Issue, error) { payload := map[string]interface{}{ diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 8312c98..30f68af 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config" giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea" @@ -40,8 +41,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /issues", h.CreateIssue) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels) + // Issue detail. + mux.HandleFunc("GET /issues/{owner}/{repo}/{index}", h.IssueDetail) + // Pull requests. mux.HandleFunc("GET /pulls", h.ListPulls) + mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail) mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview) // Settings (handled separately for auth bypass). @@ -329,6 +334,116 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) { renderPage(w, r, "Pull Requests", "pulls", content) } +// IssueDetail handles GET /issues/{owner}/{repo}/{index}. +func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) { + token := getToken(r) + owner := r.PathValue("owner") + repo := r.PathValue("repo") + indexStr := r.PathValue("index") + + index, err := strconv.ParseInt(indexStr, 10, 64) + if err != nil { + http.Error(w, "invalid issue index", http.StatusBadRequest) + return + } + + issue, err := h.Client.GetIssue(r.Context(), token, owner, repo, index) + if err != nil { + slog.Error("failed to get issue", "error", err, "owner", owner, "repo", repo, "index", index) + renderPage(w, r, "Issue Not Found", "issues", + `

Issue Not Found

Could not load the requested issue.

`) + return + } + + comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index) + if err != nil { + slog.Error("failed to get comments", "error", err) + comments = nil // non-fatal, render without comments + } + + labels, err := h.Client.GetRepoLabels(r.Context(), token, owner, repo) + if err != nil { + slog.Error("failed to get repo labels", "error", err) + labels = nil + } + + // Build the content HTML using the template. + tmpl, err := template.ParseFiles("internal/templates/issue_detail.html") + if err != nil { + slog.Error("failed to parse issue_detail template", "error", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + + type templateData struct { + Issue *giteaclient.Issue + Comments []giteaclient.Comment + AvailableLabels []giteaclient.Label + } + + data := templateData{ + Issue: issue, + Comments: comments, + AvailableLabels: labels, + } + + var buf strings.Builder + if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { + slog.Error("failed to execute issue_detail template", "error", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + + renderPage(w, r, fmt.Sprintf("Issue #%d", index), "issues", buf.String()) +} + +// PullDetail handles GET /pulls/{owner}/{repo}/{index}. +func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) { + token := getToken(r) + owner := r.PathValue("owner") + repo := r.PathValue("repo") + indexStr := r.PathValue("index") + + index, err := strconv.ParseInt(indexStr, 10, 64) + if err != nil { + http.Error(w, "invalid PR index", http.StatusBadRequest) + return + } + + pr, err := h.Client.GetPull(r.Context(), token, owner, repo, index) + if err != nil { + slog.Error("failed to get pull request", "error", err, "owner", owner, "repo", repo, "index", index) + renderPage(w, r, "PR Not Found", "pulls", + `

Pull Request Not Found

Could not load the requested pull request.

`) + return + } + + // Build the content HTML using the template. + tmpl, err := template.ParseFiles("internal/templates/pull_detail.html") + if err != nil { + slog.Error("failed to parse pull_detail template", "error", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + + type templateData struct { + Pull *giteaclient.PullRequest + } + + data := templateData{ + Pull: pr, + } + + var buf strings.Builder + if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { + slog.Error("failed to execute pull_detail template", "error", err) + http.Error(w, "template error", http.StatusInternalServerError) + return + } + + renderPage(w, r, fmt.Sprintf("PR #%d", index), "pulls", buf.String()) +} + // CreateIssue handles POST /issues. func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { token := getToken(r)