// 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(`
{{.Title}} — Gitea Mobile
{{.Content}}
Dashboard
Issues
PRs
Settings
`))
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",
`Dashboard No organizations found. Check your token permissions.
`)
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",
`Dashboard Error loading triage queue.
`)
return
}
if len(queue) == 0 {
renderPage(w, r, "Dashboard", "dashboard",
`Dashboard No items need attention. Nice work!
`)
return
}
content := `Dashboard `
for _, item := range queue {
typeBadge := `issue `
if item.Type == "pull" {
typeBadge = `PR `
}
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(`%s `, color, color, template.HTMLEscapeString(l))
}
content += fmt.Sprintf(``, 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",
`Issues No organizations found.
`)
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",
`Issues Error loading issues.
`)
return
}
if len(issues) == 0 {
renderPage(w, r, "Issues", "issues",
`Issues No open issues found.
`)
return
}
content := `Issues `
for _, issue := range issues {
labels := ""
for _, l := range issue.Labels {
labels += fmt.Sprintf(`%s `,
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(``, 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",
`Pull Requests No organizations found.
`)
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",
`Pull Requests Error loading pull requests.
`)
return
}
if len(prs) == 0 {
renderPage(w, r, "Pull Requests", "pulls",
`Pull Requests No open pull requests found.
`)
return
}
content := `Pull Requests `
for _, pr := range prs {
labels := ""
for _, l := range pr.Labels {
labels += fmt.Sprintf(`%s `,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
stats := fmt.Sprintf(`+%d -%d `, pr.Additions, pr.Deletions)
mergeStatus := ""
if pr.Mergeable {
mergeStatus = `mergeable `
}
content += fmt.Sprintf(``, 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, `Labels applied `)
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, `Review submitted `)
return
}
http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}