// 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(` {{.Title}} — Gitea Mobile
{{.Content}}
`)) 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(`
%s %s
%s/%s #%d %s
`, 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(`
%s
%s/%s #%d %s%s
`, 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(`
PR %s
%s/%s #%d %s %s %s
`, 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", `

Issue Not Found

Could not load the requested issue.

`) 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", `

Pull Request Not Found

Could not load the requested pull request.

`) 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", `

New Issue

Error loading repositories.

`) 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, `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) }