a707646200
Update ListAllIssues and ListAllPullRequests to accept state and page parameters, returning paginated results (20 per page) with HasMore metadata. ListIssues and ListPulls handlers now read page, org, and state query params; HTMX requests for page > 1 return only card HTML fragments for seamless infinite scroll. Both templates extract a reusable "cards" block and pulls.html gains a scroll sentinel matching the existing issues.html pattern. Filter changes reset to page 1. Closes leeworks-agents/gitea-mobile#32 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
768 lines
23 KiB
Go
768 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}/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"
|
|
}
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
|
|
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}
|
|
}
|
|
|
|
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page)
|
|
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
|
|
HasMore bool
|
|
NextPage int
|
|
Error string
|
|
}
|
|
|
|
selectedOrg := r.URL.Query().Get("org")
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
|
|
data := pullsData{
|
|
Orgs: orgNames,
|
|
SelectedOrg: selectedOrg,
|
|
}
|
|
|
|
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, "open", page)
|
|
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",
|
|
`<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)
|
|
}
|
|
|
|
// 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 · %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)
|
|
}
|