feat: add HTTP handlers and health endpoint
Implement all HTTP handlers using Go 1.22+ stdlib ServeMux with
HTMX fragment vs full-page response detection.
- internal/handlers/handlers.go: all route handlers
- GET /health returns 200 for K8s probes
- GET / dashboard with triage queue from aggregation layer
- GET /issues lists all issues across orgs
- GET /pulls lists all PRs across orgs
- POST /issues creates issue via aggregation layer
- POST /issues/{owner}/{repo}/{index}/labels assigns labels
- POST /pulls/{owner}/{repo}/{index}/review submits PR review
- HX-Request header detection for HTMX fragment vs full page
- Mobile-first dark theme base layout with bottom navigation
- cmd/server/main.go: refactored to use centralized route registration
- internal/handlers/handlers_test.go: unit tests for health, dashboard,
HTMX detection, input validation
Closes leeworks-agents/gitea-mobile#4
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
// Package handlers implements HTTP handlers for the Gitea Mobile application.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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)
|
||||
|
||||
// Pull requests.
|
||||
mux.HandleFunc("GET /pulls", h.ListPulls)
|
||||
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">
|
||||
<title>{{.Title}} — Gitea Mobile</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0d1117; color: #e6edf3;
|
||||
padding-bottom: calc(60px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.content { padding: 1rem; padding-top: max(1rem, env(safe-area-inset-top)); }
|
||||
h1 { font-size: 1.25rem; margin-bottom: 1rem; }
|
||||
.card {
|
||||
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
||||
padding: 0.75rem; margin-bottom: 0.5rem;
|
||||
}
|
||||
.card-title { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.25rem; }
|
||||
.card-meta { font-size: 0.75rem; color: #8b949e; }
|
||||
.label {
|
||||
display: inline-block; font-size: 0.7rem; padding: 2px 6px;
|
||||
border-radius: 10px; font-weight: 500; margin-right: 4px;
|
||||
}
|
||||
.type-badge {
|
||||
font-size: 0.65rem; text-transform: uppercase; font-weight: 700;
|
||||
padding: 1px 5px; border-radius: 4px; margin-right: 4px;
|
||||
}
|
||||
.type-issue { background: #1f6feb22; color: #58a6ff; border: 1px solid #1f6feb44; }
|
||||
.type-pull { background: #23863622; color: #3fb950; border: 1px solid #23863644; }
|
||||
.empty { text-align: center; color: #8b949e; padding: 2rem 1rem; }
|
||||
.bottom-nav {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: #161b22; border-top: 1px solid #30363d;
|
||||
display: flex; justify-content: space-around; align-items: center;
|
||||
height: 56px;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.bottom-nav a {
|
||||
color: #8b949e; text-decoration: none; font-size: 0.7rem;
|
||||
display: flex; flex-direction: column; align-items: center; padding: 4px 0;
|
||||
}
|
||||
.bottom-nav a.active { color: #58a6ff; }
|
||||
.bottom-nav svg { width: 22px; height: 22px; margin-bottom: 2px; }
|
||||
</style>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></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>
|
||||
</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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user