Compare commits

...

30 Commits

Author SHA1 Message Date
agent-company becb925456 test: add unit tests for SubmitReview and ApplyLabel client methods
Add four test functions using httptest.NewServer:
- TestApplyLabel: verifies POST request path, auth header, label IDs
  in body, and cache invalidation after success
- TestApplyLabel_Error: verifies 404 error propagation
- TestSubmitReview: verifies POST path, event/body fields, and cache
  invalidation after success
- TestSubmitReview_Error: verifies 422 error propagation

Closes leeworks-agents/gitea-mobile#127

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:08:23 +00:00
AI-Manager 417104c617 Merge pull request 'test: unit tests for ListOrgsAndRepos, CreateIssue, ListAllIssues, ListAllPullRequests' (#123) from feature/unit-tests-122-121 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 12:02:34 +00:00
agent-company d65676afe6 test: add unit tests for ListOrgsAndRepos, CreateIssue, ListAllIssues, ListAllPullRequests
Add comprehensive unit tests using mock HTTP servers for four key
aggregation methods in the Gitea client. Tests cover correct API
integration, caching behavior, sorting, state filtering, repo
filtering, pagination, and label handling.

Closes leeworks-agents/gitea-mobile#122
Closes leeworks-agents/gitea-mobile#121

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:05:34 +00:00
AI-Manager a0f786e894 Merge pull request 'feat: tablet 2-column grid layout for issue and PR lists' (#108) from feature/tablet-grid-layout-105 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 07:02:42 +00:00
AI-Manager 80aebe8e9f Merge pull request 'chore: add -race flag to CI test step' (#107) from fix/ci-runner-and-race-95-103 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 07:02:36 +00:00
agent-company b74e9de04d feat: implement tablet 2-column grid layout for issue and PR lists
Add grid layout at >= 640px breakpoint for #issue-list and #pull-list
containers, matching the existing .card-grid tablet behavior. Cards
render in a 2-column grid on tablet while maintaining single-column
on mobile.

Closes leeworks-agents/gitea-mobile#105

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:06:24 +00:00
agent-company c51ec5f752 chore: add -race flag to CI test step for concurrency bug detection
The aggregation layer uses sync.RWMutex and errgroup for concurrent
API fan-out. Enable the Go race detector in CI to catch data races
early.

Closes leeworks-agents/gitea-mobile#103

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:05:37 +00:00
AI-Manager 5c54d587aa Merge pull request 'feat: add review status and merge indicator to PR list' (#102) from feature/pr-status-icons-97 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 05:03:26 +00:00
AI-Manager c9e883da87 Merge pull request 'feat: display assignee avatar in issue list rows' (#101) from feature/assignee-avatar-98 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 05:03:13 +00:00
agent-company b0c060efae feat: add review status icon and merge status indicator to PR list rows
Add per-PR review state aggregation by fetching reviews concurrently
via the existing semaphore pattern. Display review status (approved,
changes requested, awaiting) and merge status (ready/conflicts) as
compact badges in each PR card row.

- Add ReviewState field to PullRequest struct
- Add GetPullReviewState() and EnrichPullsWithReviewState() to client
- Call enrichment in ListPulls handler after fetching PRs
- Update pulls template with review and merge badges
- Add CSS for .review-badge and .merge-badge classes

Closes leeworks-agents/gitea-mobile#97

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 03:07:52 +00:00
agent-company 047e90cd76 feat: display assignee avatar in issue list rows
Replace plain-text assignee login with a circular avatar image using
the existing .avatar CSS class. Includes title attribute for
accessibility. Unassigned issues show no avatar.

Closes leeworks-agents/gitea-mobile#98

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 03:06:00 +00:00
AI-Manager f1652bb77a Merge pull request 'feat: add close/reopen action to PR detail view' (#92) from feature/pr-close-reopen-91 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 20:42:09 +00:00
agent-company dbcfbe9138 feat: add close/reopen action to PR detail view
Add POST /pulls/{owner}/{repo}/{index}/state handler that reuses the
existing SetIssueState Gitea API call (PRs share the issues state
endpoint). The PR detail template now shows a Close PR / Reopen PR
button with HTMX for seamless state toggling without full page reload.
Also fixes the state badge to use the correct CSS class when a PR is
closed.

Closes leeworks-agents/gitea-mobile#91

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:05:21 +00:00
AI-Manager 732cedda3d Merge pull request 'fix: remove go.sum from Dockerfile COPY (no external deps)' (#90) from fix/dockerfile-go-sum-89 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 18:04:01 +00:00
agent-company 937da1962b fix: remove go.sum from Dockerfile COPY since project has no external dependencies
The project uses only Go stdlib with zero external dependencies, so go.sum
does not exist. The Dockerfile COPY instruction fails when go.sum is missing.

Closes leeworks-agents/gitea-mobile#89

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:03:03 +00:00
AI-Manager 42a61b4428 Merge pull request 'feat: make repo selector searchable on create issue form' (#88) from feature/searchable-repo-selector-87 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 16:43:35 +00:00
agent-company 96b9ef2f89 feat: make repo selector searchable on create issue form
Replace the plain <select> with an HTML5 <input> + <datalist> pair so
users can type to filter repositories. Add debounced input handler for
label loading, change event for direct datalist selection, and client-side
validation that the entered value is a known repository.

Closes leeworks-agents/gitea-mobile#87

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:04:07 +00:00
AI-Manager 338b62c294 Merge pull request 'feat: add repo-level filter to issues and pulls list views' (#86) from feature/repo-filter-83 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 15:07:51 +00:00
agent-company 6033278a86 feat: add repo-level filter to issues and pulls list views
Add a repo dropdown to the filter bar that appears when an org is
selected. The dropdown lists all repos in the selected org and
filters issues/pulls to the chosen repo. Changing the org resets
the repo filter. Infinite scroll preserves the repo filter.

Closes leeworks-agents/gitea-mobile#83

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:07:31 +00:00
AI-Manager 40f5498402 Merge pull request 'feat: add label filter to issues and pulls list views' (#85) from feature/label-filter-82 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 15:03:47 +00:00
AI-Manager f15425d7f2 Merge pull request 'feat: add comments thread to PR detail view' (#84) from feature/pr-comments-81 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 15:03:33 +00:00
agent-company 3b17d76960 feat: add label filter to issues and pulls list views
Add a label text input to the filter bar on both issues and pulls
list views. The filter is passed through to the Gitea API's native
labels query parameter for server-side filtering. Infinite scroll
preserves the label filter across page loads.

Closes leeworks-agents/gitea-mobile#82

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:07:46 +00:00
agent-company 63d0afb4e2 feat: add comments thread to PR detail view
Fetch and display PR comments in the pull request detail page,
using the same Gitea issue comments API endpoint. Shows author,
timestamp, and body for each comment, with a friendly empty state
when no comments exist.

Closes leeworks-agents/gitea-mobile#81

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:04:56 +00:00
AI-Manager 011addea5b Merge pull request 'feat: add open/closed state filter to PR list view' (#75) from feature/pr-state-filter into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 08:43:04 +00:00
AI-Manager f4c8826764 Merge pull request 'feat: add Assign action to issue detail view' (#71) from feature/assign-action into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 05:03:49 +00:00
AI-Manager 1fb3148444 Merge pull request 'feat: add label multi-select to Create Issue form' (#70) from feature/label-multiselect into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 05:03:38 +00:00
AI-Manager 6c43bd20d6 Merge pull request 'feat: add org filter dropdown to Dashboard view' (#69) from feature/dashboard-org-filter into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 05:03:25 +00:00
agent-company 485aa3f853 feat: add Assign action to issue detail view
Add the ability to assign users to issues from the mobile app:
- New ListCollaborators client method fetches repo collaborators
- New AssignIssue client method sets assignees via PATCH API
- New POST /issues/{owner}/{repo}/{index}/assignees handler
- Assignee dropdown populated with repo collaborators in issue detail
- HTMX inline response confirms assignment without page reload

Closes leeworks-agents/gitea-mobile#50

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 04:15:35 +00:00
agent-company c98b73bf39 feat: add label multi-select to Create Issue form
Add dynamic label selection when creating a new issue:
- New GET /issues/new/labels endpoint returns label checkboxes for a repo
- When a repo is selected, labels are fetched via HTMX and displayed as
  checkboxes with colored label badges
- CreateIssue handler now parses label_ids from form and passes them to
  the Gitea API
- Shows "No labels available" message when repo has no labels

Closes leeworks-agents/gitea-mobile#67

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 04:13:55 +00:00
agent-company f5734fea10 feat: add org filter dropdown to Dashboard view
Add an org filter select to the dashboard that allows users to narrow
the triage queue to a specific organization. The filter uses HTMX to
update the view without a full page reload, mirroring the pattern
already used in the issues and pulls views.

Closes leeworks-agents/gitea-mobile#68

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 04:11:55 +00:00
12 changed files with 1240 additions and 36 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
go-version: '1.22'
- name: Run tests
run: go test ./...
run: go test -race ./...
build:
runs-on: ubuntu-latest
+1 -1
View File
@@ -1,7 +1,7 @@
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server
+152 -9
View File
@@ -105,8 +105,9 @@ type PullRequest struct {
Deletions int `json:"deletions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RepoOwner string `json:"-"` // populated after fetch
RepoName string `json:"-"` // populated after fetch
RepoOwner string `json:"-"` // populated after fetch
RepoName string `json:"-"` // populated after fetch
ReviewState string `json:"-"` // aggregated review state: "approved", "changes_requested", "pending", or ""
}
// TriageItem represents an item in the triage queue.
@@ -325,7 +326,9 @@ type PaginatedPulls struct {
// ListAllIssues fetches issues across all repos in the given orgs,
// using concurrent requests with a semaphore. Results are paginated.
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedIssues, error) {
// The label parameter filters issues by label name (empty string means no filter).
// The repoFilter parameter narrows results to a single repo name (empty means all repos).
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedIssues, error) {
if state == "" {
state = "open"
}
@@ -333,7 +336,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
page = 1
}
cacheKey := fmt.Sprintf("issues-%s-%s", state, strings.Join(orgs, ","))
cacheKey := fmt.Sprintf("issues-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
var allIssues []Issue
if cached, ok := c.getFromCache(cacheKey); ok {
allIssues = cached.([]Issue)
@@ -348,6 +351,17 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
allRepos = append(allRepos, repos...)
}
// Filter to a single repo if specified.
if repoFilter != "" {
var filtered []Repo
for _, r := range allRepos {
if r.Name == repoFilter {
filtered = append(filtered, r)
}
}
allRepos = filtered
}
// Fan out issue fetching across repos.
var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent)
@@ -362,6 +376,9 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/issues?state=%s&type=issues&limit=50", r.FullName, state)
if label != "" {
path += "&labels=" + label
}
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
mu.Lock()
@@ -424,8 +441,9 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
}
// ListAllPullRequests fetches PRs across all repos in the given orgs.
// Results are paginated.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedPulls, error) {
// Results are paginated. The label parameter filters PRs by label name.
// The repoFilter parameter narrows results to a single repo name.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedPulls, error) {
if state == "" {
state = "open"
}
@@ -433,7 +451,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
page = 1
}
cacheKey := fmt.Sprintf("pulls-%s-%s", state, strings.Join(orgs, ","))
cacheKey := fmt.Sprintf("pulls-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
var allPRs []PullRequest
if cached, ok := c.getFromCache(cacheKey); ok {
allPRs = cached.([]PullRequest)
@@ -447,6 +465,17 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
allRepos = append(allRepos, repos...)
}
// Filter to a single repo if specified.
if repoFilter != "" {
var filtered []Repo
for _, r := range allRepos {
if r.Name == repoFilter {
filtered = append(filtered, r)
}
}
allRepos = filtered
}
var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
@@ -460,6 +489,9 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/pulls?state=%s&limit=50", r.FullName, state)
if label != "" {
path += "&labels=" + label
}
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
mu.Lock()
@@ -524,7 +556,7 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
// Collect all open issues across all pages.
var issues []Issue
for page := 1; ; page++ {
result, err := c.ListAllIssues(ctx, token, orgs, "open", page)
result, err := c.ListAllIssues(ctx, token, orgs, "open", page, "", "")
if err != nil {
return nil, fmt.Errorf("fetching issues for triage: %w", err)
}
@@ -537,7 +569,7 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
// Collect all open PRs across all pages.
var prs []PullRequest
for page := 1; ; page++ {
result, err := c.ListAllPullRequests(ctx, token, orgs, "open", page)
result, err := c.ListAllPullRequests(ctx, token, orgs, "open", page, "", "")
if err != nil {
return nil, fmt.Errorf("fetching PRs for triage: %w", err)
}
@@ -753,6 +785,49 @@ func (c *Client) ApplyLabel(ctx context.Context, token, owner, repo string, inde
return nil
}
// ListCollaborators fetches the list of collaborators (users with access) for a repo.
func (c *Client) ListCollaborators(ctx context.Context, token, owner, repo string) ([]string, error) {
path := fmt.Sprintf("/repos/%s/%s/collaborators?limit=50", owner, repo)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching collaborators: %w", err)
}
defer resp.Body.Close()
var users []struct {
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
return nil, fmt.Errorf("decoding collaborators: %w", err)
}
var logins []string
for _, u := range users {
logins = append(logins, u.Login)
}
return logins, nil
}
// AssignIssue sets the assignees on an issue.
func (c *Client) AssignIssue(ctx context.Context, token, owner, repo string, index int64, assignees []string) error {
payload, err := json.Marshal(map[string]interface{}{
"assignees": assignees,
})
if err != nil {
return fmt.Errorf("marshaling assignees: %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("assigning issue: %w", err)
}
resp.Body.Close()
c.InvalidateAll()
return nil
}
// SubmitReview submits a review on a pull request.
func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, index int64, reviewType, body string) error {
payload := map[string]interface{}{
@@ -870,6 +945,74 @@ func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string
return string(rendered), nil
}
// Review represents a single review on a pull request.
type Review struct {
ID int64 `json:"id"`
Body string `json:"body"`
State string `json:"state"` // "APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING"
User struct {
Login string `json:"login"`
} `json:"user"`
}
// GetPullReviewState fetches reviews for a PR and returns the aggregate state.
// Priority: changes_requested > approved > pending > "" (no reviews).
func (c *Client) GetPullReviewState(ctx context.Context, token, owner, repo string, index int64) string {
path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?limit=50", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return ""
}
defer resp.Body.Close()
var reviews []Review
if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil {
return ""
}
if len(reviews) == 0 {
return ""
}
// Aggregate: last non-comment review per user wins, then pick the "worst" state.
userState := make(map[string]string)
for _, r := range reviews {
switch r.State {
case "APPROVED", "REQUEST_CHANGES":
userState[r.User.Login] = r.State
}
}
if len(userState) == 0 {
return "pending"
}
for _, s := range userState {
if s == "REQUEST_CHANGES" {
return "changes_requested"
}
}
return "approved"
}
// EnrichPullsWithReviewState fetches review state for each PR concurrently.
func (c *Client) EnrichPullsWithReviewState(ctx context.Context, token string, pulls []PullRequest) {
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
for i := range pulls {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
pulls[idx].ReviewState = c.GetPullReviewState(ctx, token, pulls[idx].RepoOwner, pulls[idx].RepoName, pulls[idx].Number)
}(i)
}
wg.Wait()
}
// priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int {
for _, l := range labels {
+713
View File
@@ -3,8 +3,10 @@ package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -374,3 +376,714 @@ func sortTriageQueue(queue []TriageItem) {
}
}
}
// --- Issue #122: Tests for ListOrgsAndRepos and CreateIssue ---
func TestListOrgsAndRepos(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/user/orgs":
orgs := []Org{
{Name: "org1", FullName: "Organization 1"},
{Name: "org2", FullName: "Organization 2"},
}
json.NewEncoder(w).Encode(orgs)
case "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a"},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b"},
}
json.NewEncoder(w).Encode(repos)
case "/api/v1/orgs/org2/repos":
repos := []Repo{
{ID: 3, Name: "repo-c", FullName: "org2/repo-c"},
}
json.NewEncoder(w).Encode(repos)
default:
t.Errorf("unexpected request path: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
result, err := c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("got %d orgs, want 2", len(result))
}
if len(result["org1"]) != 2 {
t.Errorf("org1 has %d repos, want 2", len(result["org1"]))
}
if len(result["org2"]) != 1 {
t.Errorf("org2 has %d repos, want 1", len(result["org2"]))
}
if result["org1"][0].Name != "repo-a" {
t.Errorf("org1 repos[0].Name = %q, want %q", result["org1"][0].Name, "repo-a")
}
if result["org2"][0].Name != "repo-c" {
t.Errorf("org2 repos[0].Name = %q, want %q", result["org2"][0].Name, "repo-c")
}
}
func TestListOrgsAndRepos_Cached(t *testing.T) {
orgCallCount := 0
repoCallCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/user/orgs":
orgCallCount++
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case "/api/v1/orgs/org1/repos":
repoCallCount++
json.NewEncoder(w).Encode([]Repo{{ID: 1, Name: "repo1", FullName: "org1/repo1"}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
// First call populates cache.
_, err := c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("first call: %v", err)
}
// Second call should use cached orgs and repos (ListOrgs and ListOrgRepos both cache).
_, err = c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("second call: %v", err)
}
if orgCallCount != 1 {
t.Errorf("org endpoint called %d times, want 1 (cached)", orgCallCount)
}
if repoCallCount != 1 {
t.Errorf("repo endpoint called %d times, want 1 (cached)", repoCallCount)
}
}
func TestCreateIssue(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" {
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]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["title"] != "Test Issue" {
t.Errorf("expected title='Test Issue', got %q", body["title"])
}
if body["body"] != "Issue body here" {
t.Errorf("expected body='Issue body here', got %q", body["body"])
}
issue := map[string]interface{}{
"id": 1,
"number": 42,
"title": body["title"],
"body": body["body"],
"state": "open",
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-03-28T00:00:00Z",
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(issue)
}))
defer server.Close()
c := NewClient(server.URL)
// Pre-populate cache to verify invalidation.
c.setCache("issues-org1", "should-be-invalidated")
issue, err := c.CreateIssue(context.Background(), "test-token", "owner1", "repo1", "Test Issue", "Issue body here", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if issue.Title != "Test Issue" {
t.Errorf("issue.Title = %q, want %q", issue.Title, "Test Issue")
}
if issue.Number != 42 {
t.Errorf("issue.Number = %d, want 42", issue.Number)
}
if issue.RepoOwner != "owner1" {
t.Errorf("issue.RepoOwner = %q, want %q", issue.RepoOwner, "owner1")
}
if issue.RepoName != "repo1" {
t.Errorf("issue.RepoName = %q, want %q", issue.RepoName, "repo1")
}
// Verify cache was invalidated.
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after CreateIssue")
}
}
func TestCreateIssue_WithLabels(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
labels, ok := body["labels"]
if !ok {
t.Error("expected labels in request body")
}
labelSlice, ok := labels.([]interface{})
if !ok || len(labelSlice) != 2 {
t.Errorf("expected 2 labels, got %v", labels)
}
issue := map[string]interface{}{
"id": 2,
"number": 43,
"title": body["title"],
"body": body["body"],
"state": "open",
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-03-28T00:00:00Z",
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(issue)
}))
defer server.Close()
c := NewClient(server.URL)
issue, err := c.CreateIssue(context.Background(), "test-token", "owner1", "repo1", "Labeled Issue", "body", []int64{10, 20})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if issue.Number != 43 {
t.Errorf("issue.Number = %d, want 43", issue.Number)
}
}
// --- Issue #121: Tests for ListAllIssues and ListAllPullRequests ---
// newFanOutServer creates a mock HTTP server that serves orgs, repos, issues, and PRs
// for testing the fan-out aggregation functions.
func newFanOutServer(t *testing.T) *httptest.Server {
t.Helper()
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
}
json.NewEncoder(w).Encode(repos)
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
issues := []Issue{
{ID: 1, Number: 1, Title: "Issue A1", State: "open", UpdatedAt: now.Add(-1 * time.Hour)},
{ID: 2, Number: 2, Title: "Issue A2", State: "open", UpdatedAt: now.Add(-3 * time.Hour)},
}
json.NewEncoder(w).Encode(issues)
case r.URL.Path == "/api/v1/repos/org1/repo-b/issues":
issues := []Issue{
{ID: 3, Number: 1, Title: "Issue B1", State: "open", UpdatedAt: now.Add(-2 * time.Hour)},
}
json.NewEncoder(w).Encode(issues)
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
prs := []PullRequest{
{ID: 10, Number: 5, Title: "PR A1", State: "open", UpdatedAt: now.Add(-30 * time.Minute)},
}
json.NewEncoder(w).Encode(prs)
case r.URL.Path == "/api/v1/repos/org1/repo-b/pulls":
prs := []PullRequest{
{ID: 11, Number: 6, Title: "PR B1", State: "open", UpdatedAt: now.Add(-10 * time.Minute)},
{ID: 12, Number: 7, Title: "PR B2", State: "open", UpdatedAt: now.Add(-1 * time.Hour)},
}
json.NewEncoder(w).Encode(prs)
default:
t.Errorf("unexpected request path: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
}
func TestListAllIssues_Sorting(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
// Pre-populate org repos cache to avoid needing the /user/orgs endpoint.
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Issues) != 3 {
t.Fatalf("got %d issues, want 3", len(result.Issues))
}
// Should be sorted by UpdatedAt descending (newest first).
// Issue A1 (-1h), Issue B1 (-2h), Issue A2 (-3h).
expected := []string{"Issue A1", "Issue B1", "Issue A2"}
for i, title := range expected {
if result.Issues[i].Title != title {
t.Errorf("issues[%d].Title = %q, want %q", i, result.Issues[i].Title, title)
}
}
}
func TestListAllIssues_StateFilter(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
}
json.NewEncoder(w).Encode(repos)
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]Issue{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "closed", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "closed" {
t.Errorf("state query param = %q, want %q", stateReceived, "closed")
}
}
func TestListAllIssues_DefaultState(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]Issue{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "open" {
t.Errorf("default state = %q, want %q", stateReceived, "open")
}
}
func TestListAllIssues_RepoFilter(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "repo-a")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Only issues from repo-a should be returned.
if len(result.Issues) != 2 {
t.Fatalf("got %d issues, want 2 (only from repo-a)", len(result.Issues))
}
for _, issue := range result.Issues {
if issue.RepoName != "repo-a" {
t.Errorf("issue %q has RepoName=%q, want repo-a", issue.Title, issue.RepoName)
}
}
}
func TestListAllIssues_Pagination(t *testing.T) {
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
// Create enough issues to test pagination (PageSize = 20).
var issues []Issue
for i := 0; i < 25; i++ {
issues = append(issues, Issue{
ID: int64(i + 1),
Number: int64(i + 1),
Title: fmt.Sprintf("Issue %d", i+1),
State: "open",
UpdatedAt: now.Add(time.Duration(-i) * time.Hour),
})
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
json.NewEncoder(w).Encode(issues)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
// Page 1: should have 20 items with HasMore=true.
page1, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(page1.Issues) != 20 {
t.Errorf("page 1: got %d issues, want 20", len(page1.Issues))
}
if !page1.HasMore {
t.Error("page 1: HasMore should be true")
}
// Page 2: should have 5 items with HasMore=false.
page2, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 2, "", "")
if err != nil {
t.Fatalf("page 2: %v", err)
}
if len(page2.Issues) != 5 {
t.Errorf("page 2: got %d issues, want 5", len(page2.Issues))
}
if page2.HasMore {
t.Error("page 2: HasMore should be false")
}
}
func TestListAllPullRequests_Sorting(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Pulls) != 3 {
t.Fatalf("got %d PRs, want 3", len(result.Pulls))
}
// Should be sorted by UpdatedAt descending.
// PR B1 (-10m), PR A1 (-30m), PR B2 (-1h).
expected := []string{"PR B1", "PR A1", "PR B2"}
for i, title := range expected {
if result.Pulls[i].Title != title {
t.Errorf("pulls[%d].Title = %q, want %q", i, result.Pulls[i].Title, title)
}
}
}
func TestListAllPullRequests_StateFilter(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]PullRequest{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "closed", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "closed" {
t.Errorf("state query param = %q, want %q", stateReceived, "closed")
}
}
// --- Issue #127: Tests for ApplyLabel and SubmitReview ---
func TestApplyLabel(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/labels" {
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]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
labels, ok := body["labels"].([]interface{})
if !ok {
t.Fatalf("expected labels array, got %T", body["labels"])
}
if len(labels) != 2 {
t.Errorf("expected 2 label IDs, got %d", len(labels))
}
// Verify the label IDs are correct (JSON numbers are float64).
if labels[0].(float64) != 10 || labels[1].(float64) != 20 {
t.Errorf("expected label IDs [10, 20], got %v", labels)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": 10, "name": "bug"},
{"id": 20, "name": "enhancement"},
})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
err := c.ApplyLabel(context.Background(), "test-token", "owner1", "repo1", 42, []int64{10, 20})
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 ApplyLabel")
}
}
func TestApplyLabel_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, `{"message":"issue not found"}`)
}))
defer server.Close()
c := NewClient(server.URL)
err := c.ApplyLabel(context.Background(), "test-token", "owner1", "repo1", 999, []int64{10})
if err == nil {
t.Fatal("expected error for 404 response, got nil")
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("error should contain status code 404, got: %v", err)
}
}
func TestSubmitReview(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/pulls/7/reviews" {
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["event"] != "APPROVED" {
t.Errorf("expected event=APPROVED, got %q", body["event"])
}
if body["body"] != "Looks good!" {
t.Errorf("expected body='Looks good!', got %q", body["body"])
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"state": "APPROVED",
"body": body["body"],
})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("pulls-org1", "should-be-invalidated")
err := c.SubmitReview(context.Background(), "test-token", "owner1", "repo1", 7, "APPROVED", "Looks good!")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify cache was invalidated.
_, ok := c.getFromCache("pulls-org1")
if ok {
t.Error("expected cache to be invalidated after SubmitReview")
}
}
func TestSubmitReview_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprintln(w, `{"message":"validation failed"}`)
}))
defer server.Close()
c := NewClient(server.URL)
err := c.SubmitReview(context.Background(), "test-token", "owner1", "repo1", 7, "INVALID", "")
if err == nil {
t.Fatal("expected error for 422 response, got nil")
}
if !strings.Contains(err.Error(), "422") {
t.Errorf("error should contain status code 422, got: %v", err)
}
}
func TestListAllPullRequests_Pagination(t *testing.T) {
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
var prs []PullRequest
for i := 0; i < 25; i++ {
prs = append(prs, PullRequest{
ID: int64(i + 1),
Number: int64(i + 1),
Title: fmt.Sprintf("PR %d", i+1),
State: "open",
UpdatedAt: now.Add(time.Duration(-i) * time.Hour),
})
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
json.NewEncoder(w).Encode(prs)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
page1, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(page1.Pulls) != 20 {
t.Errorf("page 1: got %d PRs, want 20", len(page1.Pulls))
}
if !page1.HasMore {
t.Error("page 1: HasMore should be true")
}
page2, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 2, "", "")
if err != nil {
t.Fatalf("page 2: %v", err)
}
if len(page2.Pulls) != 5 {
t.Errorf("page 2: got %d PRs, want 5", len(page2.Pulls))
}
if page2.HasMore {
t.Error("page 2: HasMore should be false")
}
}
+204 -7
View File
@@ -39,8 +39,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// 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)
@@ -53,6 +55,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
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{
@@ -188,18 +191,30 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
orgs := h.getUserOrgs(r)
selectedOrg := r.URL.Query().Get("org")
type dashboardData struct {
Items []giteaclient.TriageItem
Error string
Items []giteaclient.TriageItem
Orgs []string
SelectedOrg string
Error string
}
var data dashboardData
data := dashboardData{
Orgs: orgs,
SelectedOrg: selectedOrg,
}
if len(orgs) == 0 {
data.Error = "No organizations found. Check your token permissions."
} else {
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
// 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."
@@ -235,6 +250,9 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
@@ -245,6 +263,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
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
@@ -254,6 +274,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
@@ -263,9 +285,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
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)
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues."
@@ -324,6 +356,9 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
@@ -334,6 +369,8 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
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
@@ -343,6 +380,8 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
@@ -351,9 +390,19 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
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)
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
@@ -363,6 +412,10 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
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)
}
}
}
@@ -435,6 +488,12 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
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 != "" {
@@ -459,6 +518,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
RenderedBody template.HTML
Comments []giteaclient.Comment
AvailableLabels []giteaclient.Label
Collaborators []string
}
data := templateData{
@@ -466,6 +526,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
RenderedBody: renderedBody,
Comments: comments,
AvailableLabels: labels,
Collaborators: collaborators,
}
var buf strings.Builder
@@ -510,6 +571,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
}
}
// 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 {
@@ -521,11 +589,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
type templateData struct {
Pull *giteaclient.PullRequest
RenderedBody template.HTML
Comments []giteaclient.Comment
}
data := templateData{
Pull: pr,
RenderedBody: renderedBody,
Comments: comments,
}
var buf strings.Builder
@@ -573,6 +643,38 @@ func (h *Handler) NewIssue(w http.ResponseWriter, r *http.Request) {
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)
@@ -586,6 +688,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
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")
@@ -597,7 +708,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, nil)
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) {
@@ -666,6 +777,45 @@ 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)
}
// 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)
@@ -741,6 +891,53 @@ func (h *Handler) SetIssueState(w http.ResponseWriter, r *http.Request) {
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)
+56 -7
View File
@@ -6,20 +6,24 @@
<form id="create-issue-form" hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div class="form-group">
<label for="repo-select">Repository</label>
<select id="repo-select" name="owner_repo" required>
<option value="">Select a repository...</option>
<input list="repo-options" id="repo-select" name="owner_repo"
placeholder="Type to search repositories..." required autocomplete="off">
<datalist id="repo-options">
{{range $org, $repos := .Repos}}
<optgroup label="{{$org}}">
{{range $repos}}
<option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option>
{{end}}
</optgroup>
{{end}}
</select>
</datalist>
<input type="hidden" name="owner" id="owner-input">
<input type="hidden" name="repo" id="repo-input">
</div>
<div class="form-group" id="label-section" style="display:none;">
<label>Labels</label>
<div id="label-list"></div>
</div>
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" placeholder="Issue title" required>
@@ -40,9 +44,16 @@
var repoInput = document.getElementById('repo-input');
var formError = document.getElementById('form-error');
// Build a set of valid repo values for validation.
var validRepos = {};
var options = document.getElementById('repo-options').options;
for (var i = 0; i < options.length; i++) {
validRepos[options[i].value] = true;
}
function splitOwnerRepo() {
var val = repoSelect.value;
if (val) {
if (val && val.indexOf('/') !== -1) {
var parts = val.split('/');
ownerInput.value = parts[0] || '';
repoInput.value = parts[1] || '';
@@ -52,7 +63,39 @@
}
}
repoSelect.addEventListener('change', splitOwnerRepo);
var debounceTimer = null;
repoSelect.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
} else {
labelSection.style.display = 'none';
labelList.innerHTML = '';
}
}, 300);
});
// Also handle the change event for when a datalist option is selected directly.
repoSelect.addEventListener('change', function() {
clearTimeout(debounceTimer);
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
} else {
labelSection.style.display = 'none';
labelList.innerHTML = '';
}
});
// Validate before HTMX submit.
document.getElementById('create-issue-form').addEventListener('htmx:configRequest', function(evt) {
@@ -63,6 +106,12 @@
formError.style.display = 'block';
return false;
}
if (!validRepos[repoSelect.value]) {
evt.preventDefault();
formError.textContent = 'Please select a valid repository from the list.';
formError.style.display = 'block';
return false;
}
formError.style.display = 'none';
});
+11
View File
@@ -1,6 +1,17 @@
{{define "content"}}
<h1>Dashboard</h1>
{{if gt (len .Orgs) 1}}
<div class="filter-bar">
<select name="org" hx-get="/" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
{{end}}
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Items}}
+12
View File
@@ -51,6 +51,18 @@
<div class="card" style="margin-top:1rem;">
<h2>Actions</h2>
{{if .Collaborators}}
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/assignees" hx-swap="outerHTML" style="margin-bottom:0.5rem;">
<div class="filter-bar" style="margin-bottom:0.5rem;">
<select name="assignee">
{{range .Collaborators}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
<button type="submit" class="btn btn-secondary" style="width:auto;padding:0.5rem 1rem;">Assign</button>
</div>
</form>
{{end}}
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;">
<div class="filter-bar" style="margin-bottom:0.5rem;">
<select name="label_id">
+13 -4
View File
@@ -8,13 +8,13 @@
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
{{if .Assignee}}
<span>{{.Assignee.Login}}</span>
<img src="{{.Assignee.AvatarURL}}" alt="{{.Assignee.Login}}" class="avatar" title="Assigned to {{.Assignee.Login}}">
{{end}}
</div>
</div>
{{end}}
{{if .HasMore}}
<div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
@@ -24,16 +24,25 @@
<h1>Issues</h1>
<div class="filter-bar">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state'],[name='label']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']">
{{if .Repos}}
<select name="repo" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='label']">
<option value="">All repos</option>
{{range .Repos}}<option value="{{.}}" {{if eq . $.SelectedRepo}}selected{{end}}>{{.}}</option>{{end}}
</select>
{{end}}
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='repo'],[name='label']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select>
<input type="text" name="label" placeholder="Filter by label..." value="{{.SelectedLabel}}"
hx-get="/issues" hx-trigger="input changed delay:400ms" hx-target="#main-content"
hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='repo']">
</div>
{{if .Error}}
+29 -1
View File
@@ -4,7 +4,7 @@
<div class="card">
<div class="card-meta">
<span class="type-badge type-pull">PR</span>
<span class="state-open">{{.Pull.State}}</span>
{{if eq .Pull.State "closed"}}<span class="state-closed">{{.Pull.State}}</span>{{else}}<span class="state-open">{{.Pull.State}}</span>{{end}}
<span>{{.Pull.RepoOwner}}/{{.Pull.RepoName}} #{{.Pull.Number}}</span>
{{range .Pull.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
@@ -15,6 +15,17 @@
<span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</div>
<div class="card-meta" style="margin-top:0.5rem;">
<span id="state-section">
{{if eq .Pull.State "closed"}}
<span class="state-closed" id="pull-state">{{.Pull.State}}</span>
<button class="btn btn-secondary" hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen PR</button>
{{else}}
<span class="state-open" id="pull-state">{{.Pull.State}}</span>
<button class="btn btn-danger" hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close PR</button>
{{end}}
</span>
</div>
{{if .RenderedBody}}
<div class="card-body markdown-body">{{.RenderedBody}}</div>
{{else if .Pull.Body}}
@@ -46,4 +57,21 @@
<button type="submit" class="btn btn-primary">Submit Review</button>
</form>
</div>
{{if .Comments}}
<h2>Comments</h2>
<div id="comments-list">
{{range .Comments}}
<div class="comment">
<div class="comment-header">
<strong>{{.User}}</strong>
<span>{{.CreatedAt}}</span>
</div>
<div class="comment-body">{{.Body}}</div>
</div>
{{end}}
</div>
{{else}}
<p class="empty" style="margin-top:1rem;">No comments yet.</p>
{{end}}
{{end}}
+19 -4
View File
@@ -12,12 +12,18 @@
{{end}}
<span class="diff-add">+{{.Additions}}</span>
<span class="diff-del">-{{.Deletions}}</span>
{{if .Mergeable}}<span style="color:var(--accent-green);font-size:0.7rem;">mergeable</span>{{end}}
{{if eq .ReviewState "approved"}}<span class="review-badge review-approved" title="Approved">&#10003;</span>
{{else if eq .ReviewState "changes_requested"}}<span class="review-badge review-changes" title="Changes requested">&#10007;</span>
{{else if eq .ReviewState "pending"}}<span class="review-badge review-pending" title="Awaiting review">&#9202;</span>
{{end}}
{{if .Mergeable}}<span class="merge-badge merge-ready" title="Ready to merge">&#9654; Ready</span>
{{else}}<span class="merge-badge merge-conflicts" title="Has conflicts or not mergeable">Conflicts</span>
{{end}}
</div>
</div>
{{end}}
{{if .HasMore}}
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
@@ -27,16 +33,25 @@
<h1>Pull Requests</h1>
<div class="filter-bar">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state'],[name='label']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<select name="state" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']">
{{if .Repos}}
<select name="repo" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='label']">
<option value="">All repos</option>
{{range .Repos}}<option value="{{.}}" {{if eq . $.SelectedRepo}}selected{{end}}>{{.}}</option>{{end}}
</select>
{{end}}
<select name="state" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='repo'],[name='label']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select>
<input type="text" name="label" placeholder="Filter by label..." value="{{.SelectedLabel}}"
hx-get="/pulls" hx-trigger="input changed delay:400ms" hx-target="#main-content"
hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='repo']">
</div>
{{if .Error}}
+29 -2
View File
@@ -419,6 +419,29 @@ a:active {
vertical-align: middle;
}
/* Review status badges */
.review-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 1px 4px;
border-radius: var(--radius-pill);
vertical-align: middle;
}
.review-approved { color: var(--accent-green); }
.review-changes { color: var(--accent-red); }
.review-pending { color: var(--accent-yellow); }
/* Merge status badges */
.merge-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 1px 5px;
border-radius: var(--radius-pill);
vertical-align: middle;
}
.merge-ready { color: var(--accent-green); border: 1px solid var(--accent-green); }
.merge-conflicts { color: var(--accent-red); border: 1px solid var(--accent-red); }
/* Empty state */
.empty {
text-align: center;
@@ -487,13 +510,17 @@ a:active {
max-width: 960px;
}
.card-grid {
.card-grid,
#issue-list,
#pull-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
.card-grid .card {
.card-grid .card,
#issue-list .card,
#pull-list .card {
margin-bottom: 0;
}
}