// Package handlers implements HTTP handlers for the Gitea Mobile application. package handlers import ( "fmt" "html/template" "log/slog" "net/http" "strconv" "strings" "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config" giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea" "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" ) // Handler holds shared dependencies for all HTTP handlers. type Handler struct { Config *config.Config Client *giteaclient.Client } // NewHandler creates a new Handler with the given config and client. func NewHandler(cfg *config.Config, client *giteaclient.Client) *Handler { return &Handler{ Config: cfg, Client: client, } } // RegisterRoutes registers all HTTP routes on the given ServeMux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Health endpoint. mux.HandleFunc("GET /health", h.Health) // Dashboard / triage. mux.HandleFunc("GET /", h.Dashboard) // Issues. mux.HandleFunc("GET /issues", h.ListIssues) mux.HandleFunc("GET /issues/new", h.NewIssue) mux.HandleFunc("GET /issues/new/labels", h.NewIssueLabels) mux.HandleFunc("POST /issues", h.CreateIssue) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/assignees", h.AssignIssue) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/close", h.CloseIssue) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment) // 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). settingsHandler := &SettingsHandler{ SessionSecret: h.Config.SessionSecret, SecureCookies: true, } mux.HandleFunc("/settings", settingsHandler.ServeHTTP) // Static files. mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) } // isHTMX returns true if the request is an HTMX partial request. func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } // getToken extracts the user's Gitea API token from request context. func getToken(r *http.Request) string { return middleware.TokenFromContext(r.Context()) } // getUserOrgs returns the list of org names the user belongs to. func (h *Handler) getUserOrgs(r *http.Request) []string { token := getToken(r) if token == "" { return nil } orgs, err := h.Client.ListOrgs(r.Context(), token) if err != nil { slog.Error("failed to list orgs", "error", err) return nil } var names []string for _, org := range orgs { names = append(names, org.Name) } return names } // Health handles GET /health for Kubernetes probes. func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "ok") } // basePage is the full HTML wrapper used for non-HTMX requests. var basePage = template.Must(template.New("base").Parse(` {{.Title}} — Gitea Mobile
Gitea Mobile
{{.Content}}
`)) type pageData struct { Title string ActiveTab string Content template.HTML } // renderPage renders either a full page (for regular requests) or just the // content fragment (for HTMX requests). func renderPage(w http.ResponseWriter, r *http.Request, title, activeTab string, content string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if isHTMX(r) { fmt.Fprint(w, content) return } data := pageData{ Title: title, ActiveTab: activeTab, Content: template.HTML(content), } if err := basePage.Execute(w, data); err != nil { slog.Error("template render error", "error", err) } } // Dashboard handles GET / — the triage queue. func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { // Only handle exact root path. if r.URL.Path != "/" { http.NotFound(w, r) return } token := getToken(r) orgs := h.getUserOrgs(r) selectedOrg := r.URL.Query().Get("org") type dashboardData struct { Items []giteaclient.TriageItem Orgs []string SelectedOrg string Error string } data := dashboardData{ Orgs: orgs, SelectedOrg: selectedOrg, } if len(orgs) == 0 { data.Error = "No organizations found. Check your token permissions." } else { // Determine which orgs to query. queryOrgs := orgs if selectedOrg != "" { queryOrgs = []string{selectedOrg} } queue, err := h.Client.GetTriageQueue(r.Context(), token, queryOrgs) if err != nil { slog.Error("failed to get triage queue", "error", err) data.Error = "Error loading triage queue." } else { data.Items = queue } } tmpl, err := template.ParseFiles("internal/templates/dashboard.html") if err != nil { slog.Error("failed to parse dashboard template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } var buf strings.Builder if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { slog.Error("failed to execute dashboard template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } renderPage(w, r, "Dashboard", "dashboard", buf.String()) } // ListIssues handles GET /issues. func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { token := getToken(r) orgNames := h.getUserOrgs(r) type issuesData struct { Issues []giteaclient.Issue Orgs []string SelectedOrg string SelectedState string SelectedLabel string HasMore bool NextPage int Error string } selectedOrg := r.URL.Query().Get("org") selectedState := r.URL.Query().Get("state") if selectedState == "" { selectedState = "open" } selectedLabel := r.URL.Query().Get("label") page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = 1 } data := issuesData{ Orgs: orgNames, SelectedOrg: selectedOrg, SelectedState: selectedState, SelectedLabel: selectedLabel, } if len(orgNames) == 0 { data.Error = "No organizations found." } else { // Filter to selected org if specified. queryOrgs := orgNames if selectedOrg != "" { queryOrgs = []string{selectedOrg} } result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel) if err != nil { slog.Error("failed to list issues", "error", err) data.Error = "Error loading issues." } else { data.Issues = result.Issues data.HasMore = result.HasMore if result.HasMore { data.NextPage = page + 1 } } } // For HTMX infinite-scroll requests (page > 1), return only the card fragment. if isHTMX(r) && page > 1 { tmpl, err := template.ParseFiles("internal/templates/issues.html") if err != nil { slog.Error("failed to parse issues template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } var buf strings.Builder if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil { slog.Error("failed to execute issues cards template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, buf.String()) return } tmpl, err := template.ParseFiles("internal/templates/issues.html") if err != nil { slog.Error("failed to parse issues template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } var buf strings.Builder if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { slog.Error("failed to execute issues template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } renderPage(w, r, "Issues", "issues", buf.String()) } // ListPulls handles GET /pulls. func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) { token := getToken(r) orgNames := h.getUserOrgs(r) type pullsData struct { Pulls []giteaclient.PullRequest Orgs []string SelectedOrg string SelectedState string SelectedLabel string HasMore bool NextPage int Error string } selectedOrg := r.URL.Query().Get("org") selectedState := r.URL.Query().Get("state") if selectedState == "" { selectedState = "open" } selectedLabel := r.URL.Query().Get("label") page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { page = 1 } data := pullsData{ Orgs: orgNames, SelectedOrg: selectedOrg, SelectedState: selectedState, SelectedLabel: selectedLabel, } if len(orgNames) == 0 { data.Error = "No organizations found." } else { queryOrgs := orgNames if selectedOrg != "" { queryOrgs = []string{selectedOrg} } result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel) if err != nil { slog.Error("failed to list pull requests", "error", err) data.Error = "Error loading pull requests." } else { data.Pulls = result.Pulls data.HasMore = result.HasMore if result.HasMore { data.NextPage = page + 1 } } } // For HTMX infinite-scroll requests (page > 1), return only the card fragment. if isHTMX(r) && page > 1 { tmpl, err := template.ParseFiles("internal/templates/pulls.html") if err != nil { slog.Error("failed to parse pulls template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } var buf strings.Builder if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil { slog.Error("failed to execute pulls cards template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, buf.String()) return } tmpl, err := template.ParseFiles("internal/templates/pulls.html") if err != nil { slog.Error("failed to parse pulls template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } var buf strings.Builder if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { slog.Error("failed to execute pulls template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } renderPage(w, r, "Pull Requests", "pulls", buf.String()) } // 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 } collaborators, err := h.Client.ListCollaborators(r.Context(), token, owner, repo) if err != nil { slog.Error("failed to get collaborators", "error", err) collaborators = nil } // Render markdown body if present. var renderedBody template.HTML if issue.Body != "" { rendered, err := h.Client.RenderMarkdown(r.Context(), token, issue.Body) if err != nil { slog.Warn("failed to render issue body markdown, using plain text", "error", err) } else { renderedBody = template.HTML(rendered) } } // 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 RenderedBody template.HTML Comments []giteaclient.Comment AvailableLabels []giteaclient.Label Collaborators []string } data := templateData{ Issue: issue, RenderedBody: renderedBody, Comments: comments, AvailableLabels: labels, Collaborators: collaborators, } 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 } // Render markdown body if present. var renderedBody template.HTML if pr.Body != "" { rendered, err := h.Client.RenderMarkdown(r.Context(), token, pr.Body) if err != nil { slog.Warn("failed to render PR body markdown, using plain text", "error", err) } else { renderedBody = template.HTML(rendered) } } // Fetch comments for this PR (Gitea uses the issues endpoint for PR comments). comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index) if err != nil { slog.Warn("failed to fetch PR comments", "error", err, "owner", owner, "repo", repo, "index", index) // Non-fatal: continue rendering without comments. } // 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 RenderedBody template.HTML Comments []giteaclient.Comment } data := templateData{ Pull: pr, RenderedBody: renderedBody, Comments: comments, } 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()) } // NewIssue handles GET /issues/new — renders the create-issue form. func (h *Handler) NewIssue(w http.ResponseWriter, r *http.Request) { token := getToken(r) repos, err := h.Client.ListOrgsAndRepos(r.Context(), token) if err != nil { slog.Error("failed to list repos for new issue form", "error", err) renderPage(w, r, "New Issue", "issues", `

New Issue

Error loading repositories.

`) return } tmpl, err := template.ParseFiles("internal/templates/create_issue.html") if err != nil { slog.Error("failed to parse create_issue template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } type templateData struct { Repos map[string][]giteaclient.Repo } data := templateData{Repos: repos} var buf strings.Builder if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { slog.Error("failed to execute create_issue template", "error", err) http.Error(w, "template error", http.StatusInternalServerError) return } renderPage(w, r, "New Issue", "issues", buf.String()) } // NewIssueLabels handles GET /issues/new/labels — returns label checkboxes for a repo. func (h *Handler) NewIssueLabels(w http.ResponseWriter, r *http.Request) { token := getToken(r) owner := r.URL.Query().Get("owner") repo := r.URL.Query().Get("repo") if owner == "" || repo == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, `Select a repository first.`) return } labels, err := h.Client.GetRepoLabels(r.Context(), token, owner, repo) if err != nil { slog.Error("failed to fetch labels", "error", err, "owner", owner, "repo", repo) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, `Error loading labels.`) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if len(labels) == 0 { fmt.Fprint(w, `No labels available for this repository.`) return } for _, l := range labels { fmt.Fprintf(w, ``, l.ID, template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Name)) } } // CreateIssue handles POST /issues. func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { token := getToken(r) if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } owner := r.FormValue("owner") repo := r.FormValue("repo") title := r.FormValue("title") body := r.FormValue("body") // Parse label IDs from form checkboxes. var labelIDs []int64 for _, idStr := range r.Form["label_ids"] { id, err := strconv.ParseInt(idStr, 10, 64) if err == nil { labelIDs = append(labelIDs, id) } } if owner == "" || repo == "" || title == "" { if isHTMX(r) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, `owner, repo, and title are required`) return } http.Error(w, "owner, repo, and title are required", http.StatusBadRequest) return } issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, labelIDs) if err != nil { slog.Error("failed to create issue", "error", err) if isHTMX(r) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) fmt.Fprint(w, `Failed to create issue. Please try again.`) return } http.Error(w, "failed to create issue", http.StatusInternalServerError) return } if isHTMX(r) { w.Header().Set("HX-Redirect", fmt.Sprintf("/issues/%s/%s/%d", owner, repo, issue.Number)) w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, issue.Number), http.StatusSeeOther) } // ApplyLabels handles POST /issues/{owner}/{repo}/{index}/labels. func (h *Handler) ApplyLabels(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 } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } var labelIDs []int64 for _, idStr := range r.Form["label_id"] { id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { continue } labelIDs = append(labelIDs, id) } if len(labelIDs) == 0 { http.Error(w, "no labels specified", http.StatusBadRequest) return } if err := h.Client.ApplyLabel(r.Context(), token, owner, repo, index, labelIDs); err != nil { slog.Error("failed to apply labels", "error", err, "owner", owner, "repo", repo, "index", index) http.Error(w, "failed to apply labels", http.StatusInternalServerError) return } if isHTMX(r) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, `Labels applied`) return } http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) } // AssignIssue handles POST /issues/{owner}/{repo}/{index}/assignees. func (h *Handler) AssignIssue(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 } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } assignee := r.FormValue("assignee") if assignee == "" { http.Error(w, "assignee is required", http.StatusBadRequest) return } if err := h.Client.AssignIssue(r.Context(), token, owner, repo, index, []string{assignee}); err != nil { slog.Error("failed to assign issue", "error", err, "owner", owner, "repo", repo, "index", index, "assignee", assignee) http.Error(w, "failed to assign issue", http.StatusInternalServerError) return } if isHTMX(r) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `Assigned to %s`, template.HTMLEscapeString(assignee)) return } http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) } // CloseIssue handles POST /issues/{owner}/{repo}/{index}/close. func (h *Handler) CloseIssue(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 } if err := h.Client.CloseIssue(r.Context(), token, owner, repo, index); err != nil { slog.Error("failed to close issue", "error", err, "owner", owner, "repo", repo, "index", index) http.Error(w, "failed to close issue", http.StatusInternalServerError) return } if isHTMX(r) { w.Header().Set("HX-Redirect", fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index)) w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) } // SetIssueState handles POST /issues/{owner}/{repo}/{index}/state. func (h *Handler) SetIssueState(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 } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } state := r.FormValue("state") if state != "open" && state != "closed" { http.Error(w, "state must be 'open' or 'closed'", http.StatusBadRequest) return } if err := h.Client.SetIssueState(r.Context(), token, owner, repo, index, state); err != nil { slog.Error("failed to set issue state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state) http.Error(w, "failed to update issue state", http.StatusInternalServerError) return } if isHTMX(r) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if state == "closed" { fmt.Fprintf(w, `closed `, template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index) } else { fmt.Fprintf(w, `open `, template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index) } return } http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) } // AddComment handles POST /issues/{owner}/{repo}/{index}/comment. func (h *Handler) AddComment(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 } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } body := r.FormValue("body") if body == "" { http.Error(w, "comment body is required", http.StatusBadRequest) return } comment, err := h.Client.PostComment(r.Context(), token, owner, repo, index, body) if err != nil { slog.Error("failed to post comment", "error", err, "owner", owner, "repo", repo, "index", index) http.Error(w, "failed to post comment", http.StatusInternalServerError) return } if isHTMX(r) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `
%s · %s
%s
`, template.HTMLEscapeString(comment.User), template.HTMLEscapeString(comment.CreatedAt), template.HTMLEscapeString(comment.Body)) return } http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) } // SubmitReview handles POST /pulls/{owner}/{repo}/{index}/review. func (h *Handler) SubmitReview(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 } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } reviewType := r.FormValue("event") // APPROVED, REQUEST_CHANGES, COMMENT body := r.FormValue("body") if reviewType == "" { http.Error(w, "review event type is required", http.StatusBadRequest) return } if err := h.Client.SubmitReview(r.Context(), token, owner, repo, index, reviewType, body); err != nil { slog.Error("failed to submit review", "error", err, "owner", owner, "repo", repo, "index", index) http.Error(w, "failed to submit review", http.StatusInternalServerError) return } if isHTMX(r) { w.WriteHeader(http.StatusOK) fmt.Fprint(w, `Review submitted`) return } http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther) }