// 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(`
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", `Error loading triage queue.
`) return } if len(queue) == 0 { renderPage(w, r, "Dashboard", "dashboard", `No items need attention. Nice work!
`) return } content := `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", `Error loading issues.
`) return } if len(issues) == 0 { renderPage(w, r, "Issues", "issues", `No open issues found.
`) return } content := `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", `Error loading pull requests.
`) return } if len(prs) == 0 { renderPage(w, r, "Pull Requests", "pulls", `No open pull requests found.
`) return } content := `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", `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", `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) }