diff --git a/cmd/server/main.go b/cmd/server/main.go
index 82b2554..fbe511b 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -1,14 +1,13 @@
package main
import (
- "fmt"
"log"
"log/slog"
"net/http"
"os"
- "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/handlers"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
)
@@ -24,33 +23,13 @@ func main() {
log.Fatalf("configuration error: %v", err)
}
- // Determine if cookies should be marked Secure (disable for local dev).
- secureCookies := !strings.HasPrefix(cfg.ListenAddr, "localhost") &&
- !strings.HasPrefix(cfg.ListenAddr, "127.0.0.1")
+ // Create Gitea API client.
+ client := giteaclient.NewClient(cfg.GiteaURL)
+ // Create handler with all routes.
mux := http.NewServeMux()
-
- // Health endpoint (no auth required).
- mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- fmt.Fprintln(w, "ok")
- })
-
- // Settings handler.
- settingsHandler := &handlers.SettingsHandler{
- SessionSecret: cfg.SessionSecret,
- SecureCookies: secureCookies,
- }
- mux.HandleFunc("/settings", settingsHandler.ServeHTTP)
-
- // Static file server.
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
-
- // Placeholder dashboard (will be replaced in issue #4/#5).
- mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- fmt.Fprintln(w, "
Gitea Mobile
Dashboard coming soon.
")
- })
+ h := handlers.NewHandler(cfg, client)
+ h.RegisterRoutes(mux)
// Apply middleware chain: logging -> auth.
var handler http.Handler = mux
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
new file mode 100644
index 0000000..47fd1d9
--- /dev/null
+++ b/internal/handlers/handlers.go
@@ -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(`
+
+
+
+
+ {{.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(``, 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(`
+
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)
+}
+
+// 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)
+}
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
new file mode 100644
index 0000000..af96f5a
--- /dev/null
+++ b/internal/handlers/handlers_test.go
@@ -0,0 +1,149 @@
+package handlers
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
+ giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
+)
+
+func newTestHandler() *Handler {
+ cfg := &config.Config{
+ GiteaURL: "https://gitea.example.com",
+ SessionSecret: "test-secret-that-is-at-least-32-chars-long",
+ ListenAddr: ":8080",
+ }
+ client := giteaclient.NewClient(cfg.GiteaURL)
+ return NewHandler(cfg, client)
+}
+
+func TestHealth(t *testing.T) {
+ h := newTestHandler()
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ w := httptest.NewRecorder()
+
+ h.Health(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+ if body := w.Body.String(); body != "ok\n" {
+ t.Errorf("body = %q, want %q", body, "ok\n")
+ }
+}
+
+func TestDashboard_NoToken(t *testing.T) {
+ h := newTestHandler()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ w := httptest.NewRecorder()
+
+ h.Dashboard(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+ // Without a token in context, should show "No organizations found."
+ if body := w.Body.String(); body == "" {
+ t.Error("expected non-empty response body")
+ }
+}
+
+func TestDashboard_HTMX(t *testing.T) {
+ h := newTestHandler()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("HX-Request", "true")
+ w := httptest.NewRecorder()
+
+ h.Dashboard(w, req)
+
+ // HTMX request should not include full HTML page wrapper.
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body")
+ }
+ // Should NOT contain DOCTYPE for HTMX fragment.
+ if contains(body, "= len(substr) && searchString(s, substr)
+}
+
+func searchString(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}