923 lines
29 KiB
Go
923 lines
29 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)
|
|
|
|
// 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(`<!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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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 {
|
|
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
|
|
HasMore bool
|
|
NextPage int
|
|
Error string
|
|
}
|
|
|
|
selectedOrg := r.URL.Query().Get("org")
|
|
selectedState := r.URL.Query().Get("state")
|
|
if selectedState == "" {
|
|
selectedState = "open"
|
|
}
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
|
|
data := issuesData{
|
|
Orgs: orgNames,
|
|
SelectedOrg: selectedOrg,
|
|
SelectedState: selectedState,
|
|
}
|
|
|
|
if len(orgNames) == 0 {
|
|
data.Error = "No organizations found."
|
|
} else {
|
|
// Filter to selected org if specified.
|
|
queryOrgs := orgNames
|
|
if selectedOrg != "" {
|
|
queryOrgs = []string{selectedOrg}
|
|
}
|
|
|
|
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page)
|
|
if err != nil {
|
|
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
|
|
HasMore bool
|
|
NextPage int
|
|
Error string
|
|
}
|
|
|
|
selectedOrg := r.URL.Query().Get("org")
|
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
|
|
data := pullsData{
|
|
Orgs: orgNames,
|
|
SelectedOrg: selectedOrg,
|
|
}
|
|
|
|
if len(orgNames) == 0 {
|
|
data.Error = "No organizations found."
|
|
} else {
|
|
queryOrgs := orgNames
|
|
if selectedOrg != "" {
|
|
queryOrgs = []string{selectedOrg}
|
|
}
|
|
|
|
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, "open", page)
|
|
if err != nil {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
`<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)
|
|
}
|
|
|
|
// 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)
|
|
}
|