// 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("GET /issues/new/labels", h.NewIssueLabels) 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}/state", h.SetIssueState) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment) 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(`
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 } // 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", `Could not load the requested pull request.
`) 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", `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()) } // NewIssueLabels handles GET /issues/new/labels — returns label checkboxes for a repo. func (h *Handler) NewIssueLabels(w http.ResponseWriter, r *http.Request) { token := getToken(r) owner := r.URL.Query().Get("owner") repo := r.URL.Query().Get("repo") if owner == "" || repo == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, `Select a repository first.`) return } labels, err := h.Client.GetRepoLabels(r.Context(), token, owner, repo) if err != nil { slog.Error("failed to fetch labels", "error", err, "owner", owner, "repo", repo) w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, `Error loading labels.`) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if len(labels) == 0 { fmt.Fprint(w, `No labels available for this repository.`) return } for _, l := range labels { fmt.Fprintf(w, ``, l.ID, template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Name)) } } // 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") // Parse label IDs from form checkboxes. var labelIDs []int64 for _, idStr := range r.Form["label_ids"] { id, err := strconv.ParseInt(idStr, 10, 64) if err == nil { labelIDs = append(labelIDs, id) } } if owner == "" || repo == "" || title == "" { if isHTMX(r) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) fmt.Fprint(w, `owner, repo, and title are required`) 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, labelIDs) 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, `Failed to create issue. Please try again.`) 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, `Labels applied`) 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) } // SetIssueState handles POST /issues/{owner}/{repo}/{index}/state. func (h *Handler) SetIssueState(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 } state := r.FormValue("state") if state != "open" && state != "closed" { http.Error(w, "state must be 'open' or 'closed'", http.StatusBadRequest) return } if err := h.Client.SetIssueState(r.Context(), token, owner, repo, index, state); err != nil { slog.Error("failed to set issue state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state) http.Error(w, "failed to update issue state", http.StatusInternalServerError) return } if isHTMX(r) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if state == "closed" { fmt.Fprintf(w, `closed `, template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index) } else { fmt.Fprintf(w, `open `, template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index) } 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, `