Compare commits

..

22 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 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
10 changed files with 1030 additions and 30 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
+101 -8
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.
@@ -326,7 +327,8 @@ type PaginatedPulls struct {
// ListAllIssues fetches issues across all repos in the given orgs,
// using concurrent requests with a semaphore. Results are paginated.
// The label parameter filters issues by label name (empty string means no filter).
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, label string) (PaginatedIssues, error) {
// 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"
}
@@ -334,7 +336,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
page = 1
}
cacheKey := fmt.Sprintf("issues-%s-%s-%s", state, strings.Join(orgs, ","), label)
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)
@@ -349,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)
@@ -429,7 +442,8 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
// ListAllPullRequests fetches PRs across all repos in the given orgs.
// Results are paginated. The label parameter filters PRs by label name.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int, label string) (PaginatedPulls, error) {
// 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"
}
@@ -437,7 +451,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
page = 1
}
cacheKey := fmt.Sprintf("pulls-%s-%s-%s", state, strings.Join(orgs, ","), label)
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)
@@ -451,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
@@ -531,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)
}
@@ -544,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)
}
@@ -920,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")
}
}
+91 -2
View File
@@ -55,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{
@@ -250,6 +251,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
@@ -261,6 +264,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
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
@@ -271,6 +275,7 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
@@ -280,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, selectedLabel)
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."
@@ -342,6 +357,8 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
@@ -353,6 +370,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
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
@@ -363,6 +381,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
@@ -371,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, selectedLabel)
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."
@@ -383,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)
}
}
}
@@ -538,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 {
@@ -549,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
@@ -849,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)
+39 -7
View File
@@ -6,16 +6,15 @@
<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>
@@ -45,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] || '';
@@ -57,11 +63,31 @@
}
}
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) {
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'});
@@ -80,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';
});
+10 -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}}&label={{.SelectedLabel}}" 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}}
@@ -30,13 +30,19 @@
<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'],[name='label']">
{{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']">
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}}
+16 -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}}&label={{.SelectedLabel}}" 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}}
@@ -33,13 +39,19 @@
<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'],[name='label']">
{{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']">
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;
}
}