2c32e1c6aa
Add RenderMarkdown method to gitea client that calls POST /api/v1/markdown to convert raw markdown text to safe HTML. Wire it into IssueDetail and PullDetail handlers to render body content as formatted markdown. Falls back gracefully to plain text if the API call fails. Templates updated to use RenderedBody (template.HTML) with fallback to raw Issue.Body/Pull.Body when rendering fails. Closes leeworks-agents/gitea-mobile#35 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
594 lines
18 KiB
Go
594 lines
18 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("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).
|
|
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)
|
|
|
|
if len(orgs) == 0 {
|
|
renderPage(w, r, "Dashboard", "dashboard",
|
|
`<h1>Dashboard</h1><p class="empty">No organizations found. Check your token permissions.</p>`)
|
|
return
|
|
}
|
|
|
|
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
|
|
if err != nil {
|
|
slog.Error("failed to get triage queue", "error", err)
|
|
renderPage(w, r, "Dashboard", "dashboard",
|
|
`<h1>Dashboard</h1><p class="empty">Error loading triage queue.</p>`)
|
|
return
|
|
}
|
|
|
|
if len(queue) == 0 {
|
|
renderPage(w, r, "Dashboard", "dashboard",
|
|
`<h1>Dashboard</h1><p class="empty">No items need attention. Nice work!</p>`)
|
|
return
|
|
}
|
|
|
|
content := `<h1>Dashboard</h1>`
|
|
for _, item := range queue {
|
|
typeBadge := `<span class="type-badge type-issue">issue</span>`
|
|
if item.Type == "pull" {
|
|
typeBadge = `<span class="type-badge type-pull">PR</span>`
|
|
}
|
|
|
|
labels := ""
|
|
for _, l := range item.Labels {
|
|
color := "#8b949e"
|
|
switch l {
|
|
case "P1":
|
|
color = "#f85149"
|
|
case "P2":
|
|
color = "#d29922"
|
|
case "P3":
|
|
color = "#58a6ff"
|
|
}
|
|
labels += fmt.Sprintf(`<span class="label" style="color:%s;border:1px solid %s">%s</span>`, color, color, template.HTMLEscapeString(l))
|
|
}
|
|
|
|
content += fmt.Sprintf(`<div class="card">
|
|
<div class="card-title">%s %s</div>
|
|
<div class="card-meta">%s/%s #%d %s</div>
|
|
</div>`, typeBadge, template.HTMLEscapeString(item.Title),
|
|
template.HTMLEscapeString(item.RepoOwner),
|
|
template.HTMLEscapeString(item.RepoName),
|
|
item.Number, labels)
|
|
}
|
|
|
|
renderPage(w, r, "Dashboard", "dashboard", content)
|
|
}
|
|
|
|
// ListIssues handles GET /issues.
|
|
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|
token := getToken(r)
|
|
orgs := h.getUserOrgs(r)
|
|
|
|
if len(orgs) == 0 {
|
|
renderPage(w, r, "Issues", "issues",
|
|
`<h1>Issues</h1><p class="empty">No organizations found.</p>`)
|
|
return
|
|
}
|
|
|
|
issues, err := h.Client.ListAllIssues(r.Context(), token, orgs)
|
|
if err != nil {
|
|
slog.Error("failed to list issues", "error", err)
|
|
renderPage(w, r, "Issues", "issues",
|
|
`<h1>Issues</h1><p class="empty">Error loading issues.</p>`)
|
|
return
|
|
}
|
|
|
|
if len(issues) == 0 {
|
|
renderPage(w, r, "Issues", "issues",
|
|
`<h1>Issues</h1><p class="empty">No open issues found.</p>`)
|
|
return
|
|
}
|
|
|
|
content := `<h1>Issues</h1>`
|
|
for _, issue := range issues {
|
|
labels := ""
|
|
for _, l := range issue.Labels {
|
|
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
|
|
l.Color, l.Color, template.HTMLEscapeString(l.Name))
|
|
}
|
|
|
|
assignee := ""
|
|
if issue.Assignee != nil {
|
|
assignee = fmt.Sprintf(` · %s`, template.HTMLEscapeString(issue.Assignee.Login))
|
|
}
|
|
|
|
content += fmt.Sprintf(`<div class="card">
|
|
<div class="card-title">%s</div>
|
|
<div class="card-meta">%s/%s #%d %s%s</div>
|
|
</div>`, template.HTMLEscapeString(issue.Title),
|
|
template.HTMLEscapeString(issue.RepoOwner),
|
|
template.HTMLEscapeString(issue.RepoName),
|
|
issue.Number, labels, assignee)
|
|
}
|
|
|
|
renderPage(w, r, "Issues", "issues", content)
|
|
}
|
|
|
|
// ListPulls handles GET /pulls.
|
|
func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
|
|
token := getToken(r)
|
|
orgs := h.getUserOrgs(r)
|
|
|
|
if len(orgs) == 0 {
|
|
renderPage(w, r, "Pull Requests", "pulls",
|
|
`<h1>Pull Requests</h1><p class="empty">No organizations found.</p>`)
|
|
return
|
|
}
|
|
|
|
prs, err := h.Client.ListAllPullRequests(r.Context(), token, orgs)
|
|
if err != nil {
|
|
slog.Error("failed to list pull requests", "error", err)
|
|
renderPage(w, r, "Pull Requests", "pulls",
|
|
`<h1>Pull Requests</h1><p class="empty">Error loading pull requests.</p>`)
|
|
return
|
|
}
|
|
|
|
if len(prs) == 0 {
|
|
renderPage(w, r, "Pull Requests", "pulls",
|
|
`<h1>Pull Requests</h1><p class="empty">No open pull requests found.</p>`)
|
|
return
|
|
}
|
|
|
|
content := `<h1>Pull Requests</h1>`
|
|
for _, pr := range prs {
|
|
labels := ""
|
|
for _, l := range pr.Labels {
|
|
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
|
|
l.Color, l.Color, template.HTMLEscapeString(l.Name))
|
|
}
|
|
|
|
stats := fmt.Sprintf(`<span style="color:#3fb950">+%d</span> <span style="color:#f85149">-%d</span>`, pr.Additions, pr.Deletions)
|
|
mergeStatus := ""
|
|
if pr.Mergeable {
|
|
mergeStatus = `<span style="color:#3fb950;font-size:0.7rem;">mergeable</span>`
|
|
}
|
|
|
|
content += fmt.Sprintf(`<div class="card">
|
|
<div class="card-title"><span class="type-badge type-pull">PR</span> %s</div>
|
|
<div class="card-meta">%s/%s #%d %s %s %s</div>
|
|
</div>`, template.HTMLEscapeString(pr.Title),
|
|
template.HTMLEscapeString(pr.RepoOwner),
|
|
template.HTMLEscapeString(pr.RepoName),
|
|
pr.Number, labels, stats, mergeStatus)
|
|
}
|
|
|
|
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",
|
|
`<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())
|
|
}
|
|
|
|
// 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 == "" {
|
|
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)
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|