Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company 9e7280d0e7 fix: validate owner/repo split in create_issue.html before submission
Add client-side validation to ensure a repository is selected before
form submission. Split owner/repo on both change and submit events.
Show inline error messages via form-error div. Update CreateIssue
handler to return HTMX-friendly HTML error fragments on 400/500.

Closes leeworks-agents/gitea-mobile#30

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:07:37 +00:00
3 changed files with 112 additions and 354 deletions
-45
View File
@@ -706,51 +706,6 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in
return nil return nil
} }
// CloseIssue closes an issue by setting its state to "closed".
func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error {
payload, err := json.Marshal(map[string]string{"state": "closed"})
if err != nil {
return fmt.Errorf("marshaling close request: %w", err)
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload)))
if err != nil {
return fmt.Errorf("closing issue: %w", err)
}
resp.Body.Close()
c.InvalidateAll()
return nil
}
// PostComment creates a comment on an issue and returns the created Comment.
func (c *Client) PostComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) {
payload, err := json.Marshal(map[string]string{"body": body})
if err != nil {
return nil, fmt.Errorf("marshaling comment: %w", err)
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(payload)))
if err != nil {
return nil, fmt.Errorf("posting comment: %w", err)
}
defer resp.Body.Close()
var comment Comment
if err := json.NewDecoder(resp.Body).Decode(&comment); err != nil {
return nil, fmt.Errorf("decoding comment: %w", err)
}
// Populate convenience fields.
comment.User = comment.RawUser.Login
comment.CreatedAt = comment.RawCreatedAt.Format("Jan 2, 2006 15:04")
c.InvalidateAll()
return &comment, nil
}
// priorityScore returns a numeric score for sorting (lower = higher priority). // priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int { func priorityScore(labels []string) int {
for _, l := range labels { for _, l := range labels {
-91
View File
@@ -201,97 +201,6 @@ func TestGetTriageQueue_Sorting(t *testing.T) {
} }
} }
func TestCloseIssue(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/issues/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "token test-token" {
t.Error("missing or wrong Authorization header")
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["state"] != "closed" {
t.Errorf("expected state=closed, got %q", body["state"])
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"state": "closed"})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
err := c.CloseIssue(context.Background(), "test-token", "owner1", "repo1", 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify cache was invalidated.
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after CloseIssue")
}
}
func TestPostComment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/issues/42/comments" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["body"] != "test comment" {
t.Errorf("expected body='test comment', got %q", body["body"])
}
comment := map[string]interface{}{
"id": 1,
"body": body["body"],
"user": map[string]string{"login": "testuser"},
"created_at": "2026-03-26T12:00:00Z",
}
json.NewEncoder(w).Encode(comment)
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
comment, err := c.PostComment(context.Background(), "test-token", "owner1", "repo1", 42, "test comment")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if comment.Body != "test comment" {
t.Errorf("comment.Body = %q, want %q", comment.Body, "test comment")
}
if comment.User != "testuser" {
t.Errorf("comment.User = %q, want %q", comment.User, "testuser")
}
if comment.ID != 1 {
t.Errorf("comment.ID = %d, want 1", comment.ID)
}
// Verify cache was invalidated.
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after PostComment")
}
}
// sortTriageQueue is a test helper applying the same sort as GetTriageQueue. // sortTriageQueue is a test helper applying the same sort as GetTriageQueue.
func sortTriageQueue(queue []TriageItem) { func sortTriageQueue(queue []TriageItem) {
for i := 0; i < len(queue); i++ { for i := 0; i < len(queue); i++ {
+112 -218
View File
@@ -38,11 +38,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Issues. // Issues.
mux.HandleFunc("GET /issues", h.ListIssues) mux.HandleFunc("GET /issues", h.ListIssues)
mux.HandleFunc("GET /issues/new", h.NewIssue)
mux.HandleFunc("POST /issues", h.CreateIssue) mux.HandleFunc("POST /issues", h.CreateIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/close", h.CloseIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment)
// Issue detail. // Issue detail.
mux.HandleFunc("GET /issues/{owner}/{repo}/{index}", h.IssueDetail) mux.HandleFunc("GET /issues/{owner}/{repo}/{index}", h.IssueDetail)
@@ -183,155 +180,158 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
orgs := h.getUserOrgs(r) orgs := h.getUserOrgs(r)
type dashboardData struct {
Items []giteaclient.TriageItem
Error string
}
var data dashboardData
if len(orgs) == 0 { if len(orgs) == 0 {
data.Error = "No organizations found. Check your token permissions." renderPage(w, r, "Dashboard", "dashboard",
} else { `<h1>Dashboard</h1><p class="empty">No organizations found. Check your token permissions.</p>`)
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs) return
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") queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
if err != nil { if err != nil {
slog.Error("failed to parse dashboard template", "error", err) slog.Error("failed to get triage queue", "error", err)
http.Error(w, "template error", http.StatusInternalServerError) renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">Error loading triage queue.</p>`)
return return
} }
var buf strings.Builder if len(queue) == 0 {
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { renderPage(w, r, "Dashboard", "dashboard",
slog.Error("failed to execute dashboard template", "error", err) `<h1>Dashboard</h1><p class="empty">No items need attention. Nice work!</p>`)
http.Error(w, "template error", http.StatusInternalServerError)
return return
} }
renderPage(w, r, "Dashboard", "dashboard", buf.String()) content := `<h1>Dashboard</h1>`
for _, item := range queue {
typeBadge := `<span class="type-badge type-issue">issue</span>`
if item.Type == "pull" {
typeBadge = `<span class="type-badge type-pull">PR</span>`
}
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(`<span class="label" style="color:%s;border:1px solid %s">%s</span>`, color, color, template.HTMLEscapeString(l))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s %s</div>
<div class="card-meta">%s/%s #%d %s</div>
</div>`, 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. // ListIssues handles GET /issues.
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
orgNames := h.getUserOrgs(r) orgs := h.getUserOrgs(r)
type issuesData struct { if len(orgs) == 0 {
Issues []giteaclient.Issue renderPage(w, r, "Issues", "issues",
Orgs []string `<h1>Issues</h1><p class="empty">No organizations found.</p>`)
SelectedOrg string return
SelectedState string
HasMore bool
NextPage int
Error string
} }
selectedOrg := r.URL.Query().Get("org") issues, err := h.Client.ListAllIssues(r.Context(), token, orgs)
selectedState := r.URL.Query().Get("state")
if selectedState == "" {
selectedState = "open"
}
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}
}
issues, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs)
if err != nil {
slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues."
} else {
data.Issues = issues
}
}
tmpl, err := template.ParseFiles("internal/templates/issues.html")
if err != nil { if err != nil {
slog.Error("failed to parse issues template", "error", err) slog.Error("failed to list issues", "error", err)
http.Error(w, "template error", http.StatusInternalServerError) renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">Error loading issues.</p>`)
return return
} }
var buf strings.Builder if len(issues) == 0 {
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { renderPage(w, r, "Issues", "issues",
slog.Error("failed to execute issues template", "error", err) `<h1>Issues</h1><p class="empty">No open issues found.</p>`)
http.Error(w, "template error", http.StatusInternalServerError)
return return
} }
renderPage(w, r, "Issues", "issues", buf.String()) content := `<h1>Issues</h1>`
for _, issue := range issues {
labels := ""
for _, l := range issue.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
assignee := ""
if issue.Assignee != nil {
assignee = fmt.Sprintf(` &middot; %s`, template.HTMLEscapeString(issue.Assignee.Login))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s</div>
<div class="card-meta">%s/%s #%d %s%s</div>
</div>`, 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. // ListPulls handles GET /pulls.
func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
orgNames := h.getUserOrgs(r) orgs := h.getUserOrgs(r)
type pullsData struct { if len(orgs) == 0 {
Pulls []giteaclient.PullRequest renderPage(w, r, "Pull Requests", "pulls",
Orgs []string `<h1>Pull Requests</h1><p class="empty">No organizations found.</p>`)
SelectedOrg string return
Error string
} }
selectedOrg := r.URL.Query().Get("org") prs, err := h.Client.ListAllPullRequests(r.Context(), token, orgs)
data := pullsData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
}
if len(orgNames) == 0 {
data.Error = "No organizations found."
} else {
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
prs, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
} else {
data.Pulls = prs
}
}
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
if err != nil { if err != nil {
slog.Error("failed to parse pulls template", "error", err) slog.Error("failed to list pull requests", "error", err)
http.Error(w, "template error", http.StatusInternalServerError) renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">Error loading pull requests.</p>`)
return return
} }
var buf strings.Builder if len(prs) == 0 {
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { renderPage(w, r, "Pull Requests", "pulls",
slog.Error("failed to execute pulls template", "error", err) `<h1>Pull Requests</h1><p class="empty">No open pull requests found.</p>`)
http.Error(w, "template error", http.StatusInternalServerError)
return return
} }
renderPage(w, r, "Pull Requests", "pulls", buf.String()) content := `<h1>Pull Requests</h1>`
for _, pr := range prs {
labels := ""
for _, l := range pr.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
stats := fmt.Sprintf(`<span style="color:#3fb950">+%d</span> <span style="color:#f85149">-%d</span>`, pr.Additions, pr.Deletions)
mergeStatus := ""
if pr.Mergeable {
mergeStatus = `<span style="color:#3fb950;font-size:0.7rem;">mergeable</span>`
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title"><span class="type-badge type-pull">PR</span> %s</div>
<div class="card-meta">%s/%s #%d %s %s %s</div>
</div>`, template.HTMLEscapeString(pr.Title),
template.HTMLEscapeString(pr.RepoOwner),
template.HTMLEscapeString(pr.RepoName),
pr.Number, labels, stats, mergeStatus)
}
renderPage(w, r, "Pull Requests", "pulls", content)
} }
// IssueDetail handles GET /issues/{owner}/{repo}/{index}. // IssueDetail handles GET /issues/{owner}/{repo}/{index}.
@@ -444,41 +444,6 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
renderPage(w, r, fmt.Sprintf("PR #%d", index), "pulls", buf.String()) 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())
}
// CreateIssue handles POST /issues. // CreateIssue handles POST /issues.
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
@@ -572,77 +537,6 @@ func (h *Handler) ApplyLabels(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) 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)
}
// 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 &middot; %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. // SubmitReview handles POST /pulls/{owner}/{repo}/{index}/review.
func (h *Handler) SubmitReview(w http.ResponseWriter, r *http.Request) { func (h *Handler) SubmitReview(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)