d8a590eb79
Add isTokenError() helper that detects HTTP 401/403 responses from the Gitea API, and redirectOnTokenError() that redirects to /settings with an error=token_expired query parameter. Update Dashboard, ListIssues, and ListPulls handlers to check for token errors. The settings page now displays an error banner explaining the token needs to be refreshed. Closes leeworks-agents/gitea-mobile#192 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1105 lines
35 KiB
Go
1105 lines
35 KiB
Go
// 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}/assignees", h.AssignIssue)
|
|
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)
|
|
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/state", h.SetPullState)
|
|
|
|
// 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())
|
|
}
|
|
|
|
// isTokenError returns true if the error indicates an expired or revoked API token.
|
|
func isTokenError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "API error 401") || strings.Contains(msg, "API error 403")
|
|
}
|
|
|
|
// redirectOnTokenError checks if the error is a token auth error and redirects
|
|
// to /settings with an error banner. Returns true if a redirect was performed.
|
|
func redirectOnTokenError(w http.ResponseWriter, r *http.Request, err error) bool {
|
|
if !isTokenError(err) {
|
|
return false
|
|
}
|
|
slog.Warn("Gitea API token expired or revoked, redirecting to settings", "error", err)
|
|
if isHTMX(r) {
|
|
w.Header().Set("HX-Redirect", "/settings?error=token_expired")
|
|
w.WriteHeader(http.StatusOK)
|
|
} else {
|
|
http.Redirect(w, r, "/settings?error=token_expired", http.StatusSeeOther)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
<meta name="theme-color" content="#161b22">
|
|
<link rel="manifest" href="/static/manifest.json">
|
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
|
<title>{{.Title}} — Gitea Mobile</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<script src="/static/htmx.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<header class="top-bar">
|
|
<span class="top-bar-title">Gitea Mobile</span>
|
|
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">↻</button>
|
|
</header>
|
|
<div class="content" id="main-content">
|
|
{{.Content}}
|
|
</div>
|
|
<nav class="bottom-nav">
|
|
<a href="/" {{if eq .ActiveTab "dashboard"}}class="active"{{end}}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/></svg>
|
|
Dashboard
|
|
</a>
|
|
<a href="/issues" {{if eq .ActiveTab "issues"}}class="active"{{end}}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
Issues
|
|
</a>
|
|
<a href="/pulls" {{if eq .ActiveTab "pulls"}}class="active"{{end}}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 009 9"/></svg>
|
|
PRs
|
|
</a>
|
|
<a href="/settings" {{if eq .ActiveTab "settings"}}class="active"{{end}}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
|
Settings
|
|
</a>
|
|
</nav>
|
|
<script>
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
|
|
console.log('SW registration failed:', err);
|
|
});
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`))
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// errorData holds the template data for error pages.
|
|
type errorData struct {
|
|
Code int
|
|
Title string
|
|
Message string
|
|
}
|
|
|
|
// ErrorNotFound renders a mobile-friendly 404 error page.
|
|
func (h *Handler) ErrorNotFound(w http.ResponseWriter, r *http.Request) {
|
|
data := errorData{
|
|
Code: http.StatusNotFound,
|
|
Title: "Page Not Found",
|
|
Message: "The page you are looking for does not exist or has been moved.",
|
|
}
|
|
h.renderError(w, r, data)
|
|
}
|
|
|
|
// ErrorInternal renders a mobile-friendly 500 error page.
|
|
func (h *Handler) ErrorInternal(w http.ResponseWriter, r *http.Request) {
|
|
data := errorData{
|
|
Code: http.StatusInternalServerError,
|
|
Title: "Internal Server Error",
|
|
Message: "Something went wrong on our end. Please try again later.",
|
|
}
|
|
h.renderError(w, r, data)
|
|
}
|
|
|
|
// renderError renders the error template with the given data and status code.
|
|
func (h *Handler) renderError(w http.ResponseWriter, r *http.Request, data errorData) {
|
|
tmpl, err := template.ParseFiles("internal/templates/error.html")
|
|
if err != nil {
|
|
slog.Error("failed to parse error template", "error", err)
|
|
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
|
|
return
|
|
}
|
|
|
|
var buf strings.Builder
|
|
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
|
|
slog.Error("failed to execute error template", "error", err)
|
|
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(data.Code)
|
|
renderPage(w, r, data.Title, "", buf.String())
|
|
}
|
|
|
|
// 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 != "/" {
|
|
h.ErrorNotFound(w, r)
|
|
return
|
|
}
|
|
|
|
token := getToken(r)
|
|
orgs := h.getUserOrgs(r)
|
|
selectedOrg := r.URL.Query().Get("org")
|
|
|
|
type dashboardData struct {
|
|
Items []giteaclient.TriageItem
|
|
Orgs []string
|
|
SelectedOrg string
|
|
Error string
|
|
}
|
|
|
|
data := dashboardData{
|
|
Orgs: orgs,
|
|
SelectedOrg: selectedOrg,
|
|
}
|
|
|
|
if len(orgs) == 0 {
|
|
data.Error = "No organizations found. Check your token permissions."
|
|
} else {
|
|
// Determine which orgs to query.
|
|
queryOrgs := orgs
|
|
if selectedOrg != "" {
|
|
queryOrgs = []string{selectedOrg}
|
|
}
|
|
|
|
queue, err := h.Client.GetTriageQueue(r.Context(), token, queryOrgs)
|
|
if err != nil {
|
|
if redirectOnTokenError(w, r, err) {
|
|
return
|
|
}
|
|
slog.Error("failed to get triage queue", "error", err)
|
|
data.Error = "Error loading triage queue."
|
|
} else {
|
|
data.Items = queue
|
|
}
|
|
}
|
|
|
|
tmpl, err := template.ParseFiles("internal/templates/dashboard.html")
|
|
if err != nil {
|
|
slog.Error("failed to parse dashboard template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var buf strings.Builder
|
|
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
|
|
slog.Error("failed to execute dashboard template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
renderPage(w, r, "Dashboard", "dashboard", buf.String())
|
|
}
|
|
|
|
// ListIssues handles GET /issues.
|
|
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|
token := getToken(r)
|
|
orgNames := h.getUserOrgs(r)
|
|
|
|
type issuesData struct {
|
|
Issues []giteaclient.Issue
|
|
Orgs []string
|
|
SelectedOrg string
|
|
SelectedState string
|
|
SelectedLabel string
|
|
SelectedRepo string
|
|
Repos []string
|
|
HasMore bool
|
|
NextPage int
|
|
Error string
|
|
}
|
|
|
|
selectedOrg := r.URL.Query().Get("org")
|
|
selectedState := r.URL.Query().Get("state")
|
|
if selectedState == "" {
|
|
selectedState = "open"
|
|
}
|
|
selectedLabel := r.URL.Query().Get("label")
|
|
selectedRepo := r.URL.Query().Get("repo")
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
|
|
data := issuesData{
|
|
Orgs: orgNames,
|
|
SelectedOrg: selectedOrg,
|
|
SelectedState: selectedState,
|
|
SelectedLabel: selectedLabel,
|
|
SelectedRepo: selectedRepo,
|
|
}
|
|
|
|
if len(orgNames) == 0 {
|
|
data.Error = "No organizations found."
|
|
} else {
|
|
// Filter to selected org if specified.
|
|
queryOrgs := orgNames
|
|
if selectedOrg != "" {
|
|
queryOrgs = []string{selectedOrg}
|
|
|
|
// Populate repo list for the selected org.
|
|
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
|
|
if err != nil {
|
|
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
|
|
} else {
|
|
for _, repo := range repos {
|
|
data.Repos = append(data.Repos, repo.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
|
|
if err != nil {
|
|
if redirectOnTokenError(w, r, err) {
|
|
return
|
|
}
|
|
slog.Error("failed to list issues", "error", err)
|
|
data.Error = "Error loading issues."
|
|
} else {
|
|
data.Issues = result.Issues
|
|
data.HasMore = result.HasMore
|
|
if result.HasMore {
|
|
data.NextPage = page + 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
|
|
if isHTMX(r) && page > 1 {
|
|
tmpl, err := template.ParseFiles("internal/templates/issues.html")
|
|
if err != nil {
|
|
slog.Error("failed to parse issues template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var buf strings.Builder
|
|
if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil {
|
|
slog.Error("failed to execute issues cards template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, buf.String())
|
|
return
|
|
}
|
|
|
|
tmpl, err := template.ParseFiles("internal/templates/issues.html")
|
|
if err != nil {
|
|
slog.Error("failed to parse issues template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var buf strings.Builder
|
|
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
|
|
slog.Error("failed to execute issues template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
renderPage(w, r, "Issues", "issues", buf.String())
|
|
}
|
|
|
|
// ListPulls handles GET /pulls.
|
|
func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
|
|
token := getToken(r)
|
|
orgNames := h.getUserOrgs(r)
|
|
|
|
type pullsData struct {
|
|
Pulls []giteaclient.PullRequest
|
|
Orgs []string
|
|
SelectedOrg string
|
|
SelectedState string
|
|
SelectedLabel string
|
|
SelectedRepo string
|
|
Repos []string
|
|
HasMore bool
|
|
NextPage int
|
|
Error string
|
|
}
|
|
|
|
selectedOrg := r.URL.Query().Get("org")
|
|
selectedState := r.URL.Query().Get("state")
|
|
if selectedState == "" {
|
|
selectedState = "open"
|
|
}
|
|
selectedLabel := r.URL.Query().Get("label")
|
|
selectedRepo := r.URL.Query().Get("repo")
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
|
|
data := pullsData{
|
|
Orgs: orgNames,
|
|
SelectedOrg: selectedOrg,
|
|
SelectedState: selectedState,
|
|
SelectedLabel: selectedLabel,
|
|
SelectedRepo: selectedRepo,
|
|
}
|
|
|
|
if len(orgNames) == 0 {
|
|
data.Error = "No organizations found."
|
|
} else {
|
|
queryOrgs := orgNames
|
|
if selectedOrg != "" {
|
|
queryOrgs = []string{selectedOrg}
|
|
|
|
// Populate repo list for the selected org.
|
|
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
|
|
if err != nil {
|
|
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
|
|
} else {
|
|
for _, repo := range repos {
|
|
data.Repos = append(data.Repos, repo.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
|
|
if err != nil {
|
|
if redirectOnTokenError(w, r, err) {
|
|
return
|
|
}
|
|
slog.Error("failed to list pull requests", "error", err)
|
|
data.Error = "Error loading pull requests."
|
|
} else {
|
|
data.Pulls = result.Pulls
|
|
data.HasMore = result.HasMore
|
|
if result.HasMore {
|
|
data.NextPage = page + 1
|
|
}
|
|
// Enrich PRs with review state for status icons.
|
|
if len(data.Pulls) > 0 {
|
|
h.Client.EnrichPullsWithReviewState(r.Context(), token, data.Pulls)
|
|
}
|
|
}
|
|
}
|
|
|
|
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
|
|
if isHTMX(r) && page > 1 {
|
|
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
|
|
if err != nil {
|
|
slog.Error("failed to parse pulls template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var buf strings.Builder
|
|
if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil {
|
|
slog.Error("failed to execute pulls cards template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprint(w, buf.String())
|
|
return
|
|
}
|
|
|
|
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
|
|
if err != nil {
|
|
slog.Error("failed to parse pulls template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var buf strings.Builder
|
|
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
|
|
slog.Error("failed to execute pulls template", "error", err)
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
renderPage(w, r, "Pull Requests", "pulls", buf.String())
|
|
}
|
|
|
|
// 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",
|
|
`<h1>Issue Not Found</h1><p class="empty">Could not load the requested issue.</p>`)
|
|
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
|
|
}
|
|
|
|
collaborators, err := h.Client.ListCollaborators(r.Context(), token, owner, repo)
|
|
if err != nil {
|
|
slog.Error("failed to get collaborators", "error", err)
|
|
collaborators = 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
|
|
Collaborators []string
|
|
}
|
|
|
|
data := templateData{
|
|
Issue: issue,
|
|
RenderedBody: renderedBody,
|
|
Comments: comments,
|
|
AvailableLabels: labels,
|
|
Collaborators: collaborators,
|
|
}
|
|
|
|
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",
|
|
`<h1>Pull Request Not Found</h1><p class="empty">Could not load the requested pull request.</p>`)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Fetch comments for this PR (Gitea uses the issues endpoint for PR comments).
|
|
comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index)
|
|
if err != nil {
|
|
slog.Warn("failed to fetch PR comments", "error", err, "owner", owner, "repo", repo, "index", index)
|
|
// Non-fatal: continue rendering without comments.
|
|
}
|
|
|
|
// 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
|
|
Comments []giteaclient.Comment
|
|
}
|
|
|
|
data := templateData{
|
|
Pull: pr,
|
|
RenderedBody: renderedBody,
|
|
Comments: comments,
|
|
}
|
|
|
|
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",
|
|
`<h1>New Issue</h1><p class="empty">Error loading repositories.</p>`)
|
|
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, `<span class="empty">Select a repository first.</span>`)
|
|
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, `<span class="empty">Error loading labels.</span>`)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if len(labels) == 0 {
|
|
fmt.Fprint(w, `<span class="empty">No labels available for this repository.</span>`)
|
|
return
|
|
}
|
|
|
|
for _, l := range labels {
|
|
fmt.Fprintf(w, `<label style="display:inline-block;margin:0.25rem 0.5rem 0.25rem 0;cursor:pointer;"><input type="checkbox" name="label_ids" value="%d" style="margin-right:0.25rem;"> <span class="label" style="color:#%s;border:1px solid #%s">%s</span></label>`,
|
|
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, `<span class="empty">owner, repo, and title are required</span>`)
|
|
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, `<span class="empty">Failed to create issue. Please try again.</span>`)
|
|
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, `<span style="color:#3fb950">Labels applied</span>`)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
|
|
}
|
|
|
|
// AssignIssue handles POST /issues/{owner}/{repo}/{index}/assignees.
|
|
func (h *Handler) AssignIssue(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
|
|
}
|
|
|
|
assignee := r.FormValue("assignee")
|
|
if assignee == "" {
|
|
http.Error(w, "assignee is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.Client.AssignIssue(r.Context(), token, owner, repo, index, []string{assignee}); err != nil {
|
|
slog.Error("failed to assign issue", "error", err, "owner", owner, "repo", repo, "index", index, "assignee", assignee)
|
|
http.Error(w, "failed to assign issue", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if isHTMX(r) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
fmt.Fprintf(w, `<span style="color:#3fb950">Assigned to %s</span>`, template.HTMLEscapeString(assignee))
|
|
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, `<span class="state-closed" id="issue-state">closed</span>
|
|
<button class="btn btn-secondary" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>`,
|
|
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
|
|
} else {
|
|
fmt.Fprintf(w, `<span class="state-open" id="issue-state">open</span>
|
|
<button class="btn btn-danger" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>`,
|
|
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
|
|
}
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
|
|
}
|
|
|
|
// SetPullState handles POST /pulls/{owner}/{repo}/{index}/state.
|
|
func (h *Handler) SetPullState(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 pull request 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 pull request state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state)
|
|
http.Error(w, "failed to update pull request state", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if isHTMX(r) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if state == "closed" {
|
|
fmt.Fprintf(w, `<span class="state-closed" id="pull-state">closed</span>
|
|
<button class="btn btn-secondary" hx-post="/pulls/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen PR</button>`,
|
|
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
|
|
} else {
|
|
fmt.Fprintf(w, `<span class="state-open" id="pull-state">open</span>
|
|
<button class="btn btn-danger" hx-post="/pulls/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close PR</button>`,
|
|
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
|
|
}
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("/pulls/%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, `<div class="card comment">
|
|
<div class="card-meta">%s · %s</div>
|
|
<div class="card-body">%s</div>
|
|
</div>`, template.HTMLEscapeString(comment.User), template.HTMLEscapeString(comment.CreatedAt), template.HTMLEscapeString(comment.Body))
|
|
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, `<span style="color:#3fb950">Review submitted</span>`)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
|
|
}
|