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(`
+
%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) +} + +// 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 +}