Files
gitea-mobile/internal/handlers/handlers.go
T
agent-company bcd61ff139 feat: add close/reopen and comment actions to issue detail view
Add SetIssueState client method and handler for toggling issue state
between open and closed via PATCH API. Add AddComment client method
wrapping PostComment. Register new routes POST /issues/{owner}/{repo}/{index}/state
and POST /issues/{owner}/{repo}/{index}/comments. Update issue_detail.html
template with comment form (HTMX inline append) and close/reopen button
(HTMX inline swap of state badge).

Closes leeworks-agents/gitea-mobile#29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:07:58 +00:00

761 lines
23 KiB
Go

// 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("POST /issues", h.CreateIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#161b22">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<title>{{.Title}} — Gitea Mobile</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="content" id="main-content">
{{.Content}}
</div>
<nav class="bottom-nav">
<a href="/" {{if eq .ActiveTab "dashboard"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/></svg>
Dashboard
</a>
<a href="/issues" {{if eq .ActiveTab "issues"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Issues
</a>
<a href="/pulls" {{if eq .ActiveTab "pulls"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 009 9"/></svg>
PRs
</a>
<a href="/settings" {{if eq .ActiveTab "settings"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings
</a>
</nav>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
console.log('SW registration failed:', err);
});
}
</script>
</body>
</html>`))
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)
type dashboardData struct {
Items []giteaclient.TriageItem
Error string
}
var data dashboardData
if len(orgs) == 0 {
data.Error = "No organizations found. Check your token permissions."
} else {
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
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
HasMore bool
NextPage int
Error string
}
selectedOrg := r.URL.Query().Get("org")
selectedState := r.URL.Query().Get("state")
if selectedState == "" {
selectedState = "open"
}
data := issuesData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
}
if len(orgNames) == 0 {
data.Error = "No organizations found."
} else {
// Filter to selected org if specified.
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
issues, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs)
if err != nil {
slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues."
} else {
data.Issues = issues
}
}
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
Error string
}
selectedOrg := r.URL.Query().Get("org")
data := pullsData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
}
if len(orgNames) == 0 {
data.Error = "No organizations found."
} else {
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
prs, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
} else {
data.Pulls = prs
}
}
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",
`<h1>Issue Not Found</h1><p class="empty">Could not load the requested issue.</p>`)
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
}
// 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
}
data := templateData{
Issue: issue,
RenderedBody: renderedBody,
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",
`<h1>Pull Request Not Found</h1><p class="empty">Could not load the requested pull request.</p>`)
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)
}
}
// 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
}
data := templateData{
Pull: pr,
RenderedBody: renderedBody,
}
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",
`<h1>New Issue</h1><p class="empty">Error loading repositories.</p>`)
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())
}
// 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")
if owner == "" || repo == "" || title == "" {
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `<span class="empty">owner, repo, and title are required</span>`)
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, nil)
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, `<span class="empty">Failed to create issue. Please try again.</span>`)
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, `<span style="color:#3fb950">Labels applied</span>`)
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, `<span class="state-closed" id="issue-state">closed</span>
<button class="btn btn-secondary" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
} else {
fmt.Fprintf(w, `<span class="state-open" id="issue-state">open</span>
<button class="btn btn-danger" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>`,
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, `<div class="card comment">
<div class="card-meta">%s &middot; %s</div>
<div class="card-body">%s</div>
</div>`, 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, `<span style="color:#3fb950">Review submitted</span>`)
return
}
http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}