ca49cdbbf3
Register GET /issues/new route and implement NewIssue handler that fetches orgs/repos via ListOrgsAndRepos and renders the existing create_issue.html template. Supports HTMX partial responses. Closes leeworks-agents/gitea-mobile#28 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
604 lines
18 KiB
Go
604 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("GET /issues/new", h.NewIssue)
|
|
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
|
|
}
|
|
|
|
// 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",
|
|
`<h1>Pull Request Not Found</h1><p class="empty">Could not load the requested pull request.</p>`)
|
|
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())
|
|
}
|
|
|
|
// 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 == "" {
|
|
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)
|
|
}
|