Compare commits

..

48 Commits

Author SHA1 Message Date
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
agent-company 7fa7d3f868 feat: add open/closed state filter to PR list view
Mirror the existing issues state filter pattern: read state query param
(default "open"), pass it to ListAllPullRequests instead of hardcoded
"open", and add a state select widget to the pulls filter bar with
proper hx-include for HTMX partial reloads and infinite scroll.

Closes leeworks-agents/gitea-mobile#72

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:06:24 +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
AI-Manager 88efe831bc Merge pull request 'feat: add backend pagination for infinite scroll in issues and pulls' (#66) from feature/pagination-infinite-scroll into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 04:05:57 +00:00
AI-Manager 65863a3098 Merge pull request 'feat: add close/reopen and comment actions to issue detail view' (#65) from feature/close-comment-actions into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 04:05:52 +00:00
AI-Manager b8f217b9b8 Merge pull request 'feat: add pull-to-refresh button for all list views' (#64) from feature/pull-to-refresh into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-27 04:05:47 +00:00
agent-company a707646200 feat: add backend pagination support for infinite scroll in issues and pulls
Update ListAllIssues and ListAllPullRequests to accept state and page
parameters, returning paginated results (20 per page) with HasMore
metadata. ListIssues and ListPulls handlers now read page, org, and
state query params; HTMX requests for page > 1 return only card HTML
fragments for seamless infinite scroll. Both templates extract a
reusable "cards" block and pulls.html gains a scroll sentinel matching
the existing issues.html pattern. Filter changes reset to page 1.

Closes leeworks-agents/gitea-mobile#32

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:08:54 +00:00
agent-company bcd61ff139 feat: add close/reopen and comment actions to issue detail view
Add SetIssueState client method and handler for toggling issue state
between open and closed via PATCH API. Add AddComment client method
wrapping PostComment. Register new routes POST /issues/{owner}/{repo}/{index}/state
and POST /issues/{owner}/{repo}/{index}/comments. Update issue_detail.html
template with comment form (HTMX inline append) and close/reopen button
(HTMX inline swap of state badge).

Closes leeworks-agents/gitea-mobile#29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:07:58 +00:00
agent-company 74a5426755 feat: add pull-to-refresh button to top bar for all list views
Add a sticky top bar with a refresh button that uses HTMX to re-fetch
the current view content without a full page reload. Works on dashboard,
issues, and pulls views via the shared layout template.

Closes leeworks-agents/gitea-mobile#51

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:07:01 +00:00
AI-Manager 851791e02f Merge pull request 'feat: render issue/PR body as markdown via Gitea API' (#49) from feature/render-markdown-rebase2 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 17:49:56 +00:00
agent-company 8b6950f88b feat: render issue and PR body as markdown via Gitea API
Add RenderMarkdown method to gitea client that calls POST /api/v1/markdown
to convert raw markdown text to safe HTML. Wire it into IssueDetail and
PullDetail handlers to render body content as formatted markdown.
Falls back gracefully to plain text if the API call fails.

Templates updated to use RenderedBody (template.HTML) with fallback
to raw Issue.Body/Pull.Body when rendering fails.

Closes leeworks-agents/gitea-mobile#35

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:49:05 +00:00
AI-Manager 8c7e74286b Merge pull request 'fix: validate owner/repo split in create-issue form' (#47) from fix/create-issue-validation-rebase into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 17:48:48 +00:00
agent-company 77f661fa2c fix: validate owner/repo split in create_issue.html before submission
Add client-side validation to ensure a repository is selected before
form submission. Split owner/repo on both change and submit events.
Show inline error messages via form-error div. Update CreateIssue
handler to return HTMX-friendly HTML error fragments on 400/500.

Closes leeworks-agents/gitea-mobile#30

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:47:58 +00:00
AI-Manager 216d9beac2 Merge pull request 'feat: add GET /issues/new handler for create-issue form' (#43) from feature/issues-new-handler into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 17:47:42 +00:00
AI-Manager 9a8834d234 Merge pull request 'refactor: wire Dashboard, ListIssues, ListPulls to templates' (#45) from feature/template-refactor into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 17:46:10 +00:00
AI-Manager e9717278f8 Merge pull request 'feat: implement CloseIssue and PostComment client methods' (#42) from feature/close-issue-post-comment into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 17:44:59 +00:00
agent-company d433801da6 refactor: wire Dashboard, ListIssues, and ListPulls to use template files
Replace inline fmt.Sprintf HTML generation in Dashboard, ListIssues,
and ListPulls handlers with template.ParseFiles rendering of
dashboard.html, issues.html, and pulls.html respectively.

ListIssues now reads ?org= and ?state= query params to filter results.
ListPulls now reads ?org= query param to filter results.

Closes leeworks-agents/gitea-mobile#34

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:08:58 +00:00
agent-company ca49cdbbf3 feat: add GET /issues/new handler to serve create-issue form
Register GET /issues/new route and implement NewIssue handler that
fetches orgs/repos via ListOrgsAndRepos and renders the existing
create_issue.html template. Supports HTMX partial responses.

Closes leeworks-agents/gitea-mobile#28

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:06:45 +00:00
agent-company added0778e feat: implement CloseIssue and PostComment methods in gitea client
Add CloseIssue (PATCH state=closed) and PostComment (POST comment body)
methods to the Gitea client with cache invalidation. Add corresponding
handler routes POST /issues/{owner}/{repo}/{index}/close and
POST /issues/{owner}/{repo}/{index}/comment with HTMX support.
Include unit tests for both client methods.

Closes leeworks-agents/gitea-mobile#36

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:06:00 +00:00
AI-Manager 919a91d6aa Merge pull request 'feat: implement issue and PR detail handlers' (#27) from feature/issue-pr-detail-handlers into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 11:04:40 +00:00
AI-Manager 3c9a947017 Merge pull request 'fix: replace $GITHUB_OUTPUT with inline env vars in CI' (#26) from fix/remove-github-output into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 11:04:28 +00:00
agent-company 04e1f21405 feat: implement issue and PR detail handlers with routes
Add GET /issues/{owner}/{repo}/{index} and GET /pulls/{owner}/{repo}/{index}
routes that fetch individual issues/PRs from the Gitea API and render them
using the existing issue_detail.html and pull_detail.html templates.

New client methods:
- GetIssue: fetch a single issue by owner/repo/index
- GetPull: fetch a single pull request by owner/repo/index
- GetIssueComments: fetch comments for an issue
- GetRepoLabels: fetch available labels for a repository

Both handlers support HTMX fragment responses and full-page rendering,
consistent with the existing handler pattern.

Closes leeworks-agents/gitea-mobile#24

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:05:17 +00:00
14 changed files with 2352 additions and 317 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
+429 -25
View File
@@ -107,6 +107,7 @@ type PullRequest struct {
UpdatedAt time.Time `json:"updated_at"`
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.
@@ -308,26 +309,60 @@ func (c *Client) ListOrgsAndRepos(ctx context.Context, token string) (map[string
return result, nil
}
// ListAllIssues fetches all open issues across all repos in the given orgs,
// using concurrent requests with a semaphore.
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string) ([]Issue, error) {
cacheKey := fmt.Sprintf("issues-%s", strings.Join(orgs, ","))
if cached, ok := c.getFromCache(cacheKey); ok {
return cached.([]Issue), nil
// PageSize is the number of items returned per page for paginated listings.
const PageSize = 20
// PaginatedIssues holds a page of issues along with pagination metadata.
type PaginatedIssues struct {
Issues []Issue
HasMore bool
}
// PaginatedPulls holds a page of pull requests along with pagination metadata.
type PaginatedPulls struct {
Pulls []PullRequest
HasMore bool
}
// 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).
// 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"
}
if page < 1 {
page = 1
}
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)
} else {
// First, collect all repos for the given orgs.
var allRepos []Repo
for _, org := range orgs {
repos, err := c.ListOrgRepos(ctx, token, org)
if err != nil {
return nil, fmt.Errorf("listing repos for %s: %w", org, err)
return PaginatedIssues{}, fmt.Errorf("listing repos for %s: %w", org, err)
}
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 allIssues []Issue
var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
@@ -340,7 +375,10 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string)
sem <- struct{}{}
defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/issues?state=open&type=issues&limit=50", r.FullName)
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()
@@ -377,7 +415,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string)
wg.Wait()
if firstErr != nil {
return nil, firstErr
return PaginatedIssues{}, firstErr
}
// Sort by updated time, newest first.
@@ -386,26 +424,58 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string)
})
c.setCache(cacheKey, allIssues)
return allIssues, nil
}
// ListAllPullRequests fetches all open PRs across all repos in the given orgs.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string) ([]PullRequest, error) {
cacheKey := fmt.Sprintf("pulls-%s", strings.Join(orgs, ","))
if cached, ok := c.getFromCache(cacheKey); ok {
return cached.([]PullRequest), nil
}
// Paginate.
start := (page - 1) * PageSize
if start >= len(allIssues) {
return PaginatedIssues{}, nil
}
end := start + PageSize
hasMore := end < len(allIssues)
if end > len(allIssues) {
end = len(allIssues)
}
return PaginatedIssues{Issues: allIssues[start:end], HasMore: hasMore}, nil
}
// ListAllPullRequests fetches PRs across all repos in the given orgs.
// 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"
}
if page < 1 {
page = 1
}
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)
} else {
var allRepos []Repo
for _, org := range orgs {
repos, err := c.ListOrgRepos(ctx, token, org)
if err != nil {
return nil, fmt.Errorf("listing repos for %s: %w", org, err)
return PaginatedPulls{}, fmt.Errorf("listing repos for %s: %w", org, err)
}
allRepos = append(allRepos, repos...)
}
var allPRs []PullRequest
// 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
@@ -418,7 +488,10 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
sem <- struct{}{}
defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/pulls?state=open&limit=50", r.FullName)
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()
@@ -454,7 +527,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
wg.Wait()
if firstErr != nil {
return nil, firstErr
return PaginatedPulls{}, firstErr
}
sort.Slice(allPRs, func(i, j int) bool {
@@ -462,20 +535,49 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
})
c.setCache(cacheKey, allPRs)
return allPRs, nil
}
// Paginate.
start := (page - 1) * PageSize
if start >= len(allPRs) {
return PaginatedPulls{}, nil
}
end := start + PageSize
hasMore := end < len(allPRs)
if end > len(allPRs) {
end = len(allPRs)
}
return PaginatedPulls{Pulls: allPRs[start:end], HasMore: hasMore}, nil
}
// GetTriageQueue returns unassigned issues and PRs needing review, sorted by priority.
func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) {
issues, err := c.ListAllIssues(ctx, token, orgs)
// Collect all open issues across all pages.
var issues []Issue
for page := 1; ; page++ {
result, err := c.ListAllIssues(ctx, token, orgs, "open", page, "", "")
if err != nil {
return nil, fmt.Errorf("fetching issues for triage: %w", err)
}
issues = append(issues, result.Issues...)
if !result.HasMore {
break
}
}
prs, err := c.ListAllPullRequests(ctx, token, orgs)
// Collect all open PRs across all pages.
var prs []PullRequest
for page := 1; ; page++ {
result, err := c.ListAllPullRequests(ctx, token, orgs, "open", page, "", "")
if err != nil {
return nil, fmt.Errorf("fetching PRs for triage: %w", err)
}
prs = append(prs, result.Pulls...)
if !result.HasMore {
break
}
}
var queue []TriageItem
@@ -530,6 +632,103 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
return queue, nil
}
// Comment represents a comment on an issue or pull request.
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
User string `json:"-"` // populated from nested object
CreatedAt string `json:"-"` // formatted after fetch
RawUser struct {
Login string `json:"login"`
} `json:"user"`
RawCreatedAt time.Time `json:"created_at"`
}
// Label represents a Gitea label (used for available labels list).
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
// GetIssue fetches a single issue by owner, repo, and index.
func (c *Client) GetIssue(ctx context.Context, token, owner, repo string, index int64) (*Issue, error) {
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching issue: %w", err)
}
defer resp.Body.Close()
var issue Issue
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
return nil, fmt.Errorf("decoding issue: %w", err)
}
issue.RepoOwner = owner
issue.RepoName = repo
return &issue, nil
}
// GetPull fetches a single pull request by owner, repo, and index.
func (c *Client) GetPull(ctx context.Context, token, owner, repo string, index int64) (*PullRequest, error) {
path := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching pull request: %w", err)
}
defer resp.Body.Close()
var pr PullRequest
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
return nil, fmt.Errorf("decoding pull request: %w", err)
}
pr.RepoOwner = owner
pr.RepoName = repo
return &pr, nil
}
// GetIssueComments fetches comments for an issue or pull request.
func (c *Client) GetIssueComments(ctx context.Context, token, owner, repo string, index int64) ([]Comment, error) {
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments?limit=50", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching comments: %w", err)
}
defer resp.Body.Close()
var comments []Comment
if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil {
return nil, fmt.Errorf("decoding comments: %w", err)
}
// Populate convenience fields.
for i := range comments {
comments[i].User = comments[i].RawUser.Login
comments[i].CreatedAt = comments[i].RawCreatedAt.Format("Jan 2, 2006 15:04")
}
return comments, nil
}
// GetRepoLabels fetches all labels for a repository.
func (c *Client) GetRepoLabels(ctx context.Context, token, owner, repo string) ([]Label, error) {
path := fmt.Sprintf("/repos/%s/%s/labels?limit=50", owner, repo)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching labels: %w", err)
}
defer resp.Body.Close()
var labels []Label
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
return nil, fmt.Errorf("decoding labels: %w", err)
}
return labels, nil
}
// CreateIssue creates a new issue in the specified repository.
func (c *Client) CreateIssue(ctx context.Context, token, owner, repo, title, body string, labels []int64) (*Issue, error) {
payload := map[string]interface{}{
@@ -586,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{}{
@@ -609,6 +851,168 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in
return nil
}
// CloseIssue closes an issue by setting its state to "closed".
func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error {
return c.SetIssueState(ctx, token, owner, repo, index, "closed")
}
// SetIssueState sets an issue's state (e.g. "open" or "closed").
func (c *Client) SetIssueState(ctx context.Context, token, owner, repo string, index int64, state string) error {
payload, err := json.Marshal(map[string]string{"state": state})
if err != nil {
return fmt.Errorf("marshaling state change: %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("setting issue state: %w", err)
}
resp.Body.Close()
c.InvalidateAll()
return nil
}
// AddComment creates a comment on an issue and returns the created Comment.
func (c *Client) AddComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) {
return c.PostComment(ctx, token, owner, repo, index, body)
}
// PostComment creates a comment on an issue and returns the created Comment.
func (c *Client) PostComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) {
payload, err := json.Marshal(map[string]string{"body": body})
if err != nil {
return nil, fmt.Errorf("marshaling comment: %w", err)
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(payload)))
if err != nil {
return nil, fmt.Errorf("posting comment: %w", err)
}
defer resp.Body.Close()
var comment Comment
if err := json.NewDecoder(resp.Body).Decode(&comment); err != nil {
return nil, fmt.Errorf("decoding comment: %w", err)
}
// Populate convenience fields.
comment.User = comment.RawUser.Login
comment.CreatedAt = comment.RawCreatedAt.Format("Jan 2, 2006 15:04")
c.InvalidateAll()
return &comment, nil
}
// RenderMarkdown renders raw markdown text to HTML using the Gitea API.
// Falls back to the raw text if the API call fails.
func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string, error) {
payload, err := json.Marshal(map[string]string{
"Text": text,
"Mode": "gfm",
})
if err != nil {
return text, fmt.Errorf("marshaling markdown request: %w", err)
}
url := c.baseURL + "/api/v1/markdown"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(payload)))
if err != nil {
return text, fmt.Errorf("creating markdown request: %w", err)
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/html")
resp, err := c.httpClient.Do(req)
if err != nil {
return text, fmt.Errorf("executing markdown request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return text, fmt.Errorf("markdown API error %d", resp.StatusCode)
}
rendered, err := io.ReadAll(resp.Body)
if err != nil {
return text, fmt.Errorf("reading markdown response: %w", err)
}
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 {
+739
View File
@@ -3,6 +3,7 @@ package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -201,6 +202,167 @@ func TestGetTriageQueue_Sorting(t *testing.T) {
}
}
func TestCloseIssue(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/issues/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "token test-token" {
t.Error("missing or wrong Authorization header")
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["state"] != "closed" {
t.Errorf("expected state=closed, got %q", body["state"])
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"state": "closed"})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
err := c.CloseIssue(context.Background(), "test-token", "owner1", "repo1", 42)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify cache was invalidated.
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after CloseIssue")
}
}
func TestSetIssueState(t *testing.T) {
tests := []struct {
name string
state string
}{
{"close", "closed"},
{"reopen", "open"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/issues/42" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["state"] != tt.state {
t.Errorf("expected state=%q, got %q", tt.state, body["state"])
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"state": tt.state})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
err := c.SetIssueState(context.Background(), "test-token", "owner1", "repo1", 42, tt.state)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after SetIssueState")
}
})
}
}
func TestAddComment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
comment := map[string]interface{}{
"id": 1,
"body": "test",
"user": map[string]string{"login": "testuser"},
"created_at": "2026-03-26T12:00:00Z",
}
json.NewEncoder(w).Encode(comment)
}))
defer server.Close()
c := NewClient(server.URL)
comment, err := c.AddComment(context.Background(), "test-token", "owner1", "repo1", 42, "test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if comment.Body != "test" {
t.Errorf("comment.Body = %q, want %q", comment.Body, "test")
}
}
func TestPostComment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/issues/42/comments" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
if body["body"] != "test comment" {
t.Errorf("expected body='test comment', got %q", body["body"])
}
comment := map[string]interface{}{
"id": 1,
"body": body["body"],
"user": map[string]string{"login": "testuser"},
"created_at": "2026-03-26T12:00:00Z",
}
json.NewEncoder(w).Encode(comment)
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
comment, err := c.PostComment(context.Background(), "test-token", "owner1", "repo1", 42, "test comment")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if comment.Body != "test comment" {
t.Errorf("comment.Body = %q, want %q", comment.Body, "test comment")
}
if comment.User != "testuser" {
t.Errorf("comment.User = %q, want %q", comment.User, "testuser")
}
if comment.ID != 1 {
t.Errorf("comment.ID = %d, want 1", comment.ID)
}
// Verify cache was invalidated.
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after PostComment")
}
}
// sortTriageQueue is a test helper applying the same sort as GetTriageQueue.
func sortTriageQueue(queue []TriageItem) {
for i := 0; i < len(queue); i++ {
@@ -213,3 +375,580 @@ 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")
}
}
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")
}
}
+673 -102
View File
@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
@@ -37,12 +38,24 @@ 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)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment)
// Issue detail.
mux.HandleFunc("GET /issues/{owner}/{repo}/{index}", h.IssueDetail)
// Pull requests.
mux.HandleFunc("GET /pulls", h.ListPulls)
mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/state", h.SetPullState)
// Settings (handled separately for auth bypass).
settingsHandler := &SettingsHandler{
@@ -107,6 +120,10 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<script src="/static/htmx.min.js"></script>
</head>
<body>
<header class="top-bar">
<span class="top-bar-title">Gitea Mobile</span>
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">&#8635;</button>
</header>
<div class="content" id="main-content">
{{.Content}}
</div>
@@ -174,159 +191,488 @@ 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
Orgs []string
SelectedOrg string
Error string
}
data := dashboardData{
Orgs: orgs,
SelectedOrg: selectedOrg,
}
if len(orgs) == 0 {
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">No organizations found. Check your token permissions.</p>`)
return
data.Error = "No organizations found. Check your token permissions."
} else {
// Determine which orgs to query.
queryOrgs := orgs
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
queue, err := h.Client.GetTriageQueue(r.Context(), token, queryOrgs)
if err != nil {
slog.Error("failed to get triage queue", "error", err)
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">Error loading triage queue.</p>`)
data.Error = "Error loading triage queue."
} else {
data.Items = queue
}
}
tmpl, err := template.ParseFiles("internal/templates/dashboard.html")
if err != nil {
slog.Error("failed to parse dashboard template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
if len(queue) == 0 {
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">No items need attention. Nice work!</p>`)
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute dashboard template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
content := `<h1>Dashboard</h1>`
for _, item := range queue {
typeBadge := `<span class="type-badge type-issue">issue</span>`
if item.Type == "pull" {
typeBadge = `<span class="type-badge type-pull">PR</span>`
}
labels := ""
for _, l := range item.Labels {
color := "#8b949e"
switch l {
case "P1":
color = "#f85149"
case "P2":
color = "#d29922"
case "P3":
color = "#58a6ff"
}
labels += fmt.Sprintf(`<span class="label" style="color:%s;border:1px solid %s">%s</span>`, color, color, template.HTMLEscapeString(l))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s %s</div>
<div class="card-meta">%s/%s #%d %s</div>
</div>`, typeBadge, template.HTMLEscapeString(item.Title),
template.HTMLEscapeString(item.RepoOwner),
template.HTMLEscapeString(item.RepoName),
item.Number, labels)
}
renderPage(w, r, "Dashboard", "dashboard", content)
renderPage(w, r, "Dashboard", "dashboard", buf.String())
}
// ListIssues handles GET /issues.
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
orgs := h.getUserOrgs(r)
orgNames := h.getUserOrgs(r)
if len(orgs) == 0 {
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">No organizations found.</p>`)
return
type issuesData struct {
Issues []giteaclient.Issue
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
}
issues, err := h.Client.ListAllIssues(r.Context(), token, orgs)
selectedOrg := r.URL.Query().Get("org")
selectedState := r.URL.Query().Get("state")
if selectedState == "" {
selectedState = "open"
}
selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
data := issuesData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
data.Error = "No organizations found."
} else {
// Filter to selected org if specified.
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
}
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
slog.Error("failed to list issues", "error", err)
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">Error loading issues.</p>`)
data.Error = "Error loading issues."
} else {
data.Issues = result.Issues
data.HasMore = result.HasMore
if result.HasMore {
data.NextPage = page + 1
}
}
}
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
if isHTMX(r) && page > 1 {
tmpl, err := template.ParseFiles("internal/templates/issues.html")
if err != nil {
slog.Error("failed to parse issues template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil {
slog.Error("failed to execute issues cards template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, buf.String())
return
}
if len(issues) == 0 {
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">No open issues found.</p>`)
tmpl, err := template.ParseFiles("internal/templates/issues.html")
if err != nil {
slog.Error("failed to parse issues template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
content := `<h1>Issues</h1>`
for _, issue := range issues {
labels := ""
for _, l := range issue.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute issues template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
assignee := ""
if issue.Assignee != nil {
assignee = fmt.Sprintf(` &middot; %s`, template.HTMLEscapeString(issue.Assignee.Login))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s</div>
<div class="card-meta">%s/%s #%d %s%s</div>
</div>`, template.HTMLEscapeString(issue.Title),
template.HTMLEscapeString(issue.RepoOwner),
template.HTMLEscapeString(issue.RepoName),
issue.Number, labels, assignee)
}
renderPage(w, r, "Issues", "issues", content)
renderPage(w, r, "Issues", "issues", buf.String())
}
// ListPulls handles GET /pulls.
func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
orgs := h.getUserOrgs(r)
orgNames := h.getUserOrgs(r)
if len(orgs) == 0 {
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">No organizations found.</p>`)
return
type pullsData struct {
Pulls []giteaclient.PullRequest
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
}
prs, err := h.Client.ListAllPullRequests(r.Context(), token, orgs)
selectedOrg := r.URL.Query().Get("org")
selectedState := r.URL.Query().Get("state")
if selectedState == "" {
selectedState = "open"
}
selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
data := pullsData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
data.Error = "No organizations found."
} else {
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
}
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">Error loading pull requests.</p>`)
data.Error = "Error loading pull requests."
} else {
data.Pulls = result.Pulls
data.HasMore = result.HasMore
if result.HasMore {
data.NextPage = page + 1
}
// Enrich PRs with review state for status icons.
if len(data.Pulls) > 0 {
h.Client.EnrichPullsWithReviewState(r.Context(), token, data.Pulls)
}
}
}
// For HTMX infinite-scroll requests (page > 1), return only the card fragment.
if isHTMX(r) && page > 1 {
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
if err != nil {
slog.Error("failed to parse pulls template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "cards", data); err != nil {
slog.Error("failed to execute pulls cards template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, buf.String())
return
}
if len(prs) == 0 {
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">No open pull requests found.</p>`)
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
if err != nil {
slog.Error("failed to parse pulls template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
content := `<h1>Pull Requests</h1>`
for _, pr := range prs {
labels := ""
for _, l := range pr.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute pulls template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
stats := fmt.Sprintf(`<span style="color:#3fb950">+%d</span> <span style="color:#f85149">-%d</span>`, pr.Additions, pr.Deletions)
mergeStatus := ""
if pr.Mergeable {
mergeStatus = `<span style="color:#3fb950;font-size:0.7rem;">mergeable</span>`
renderPage(w, r, "Pull Requests", "pulls", buf.String())
}
// IssueDetail handles GET /issues/{owner}/{repo}/{index}.
func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title"><span class="type-badge type-pull">PR</span> %s</div>
<div class="card-meta">%s/%s #%d %s %s %s</div>
</div>`, template.HTMLEscapeString(pr.Title),
template.HTMLEscapeString(pr.RepoOwner),
template.HTMLEscapeString(pr.RepoName),
pr.Number, labels, stats, mergeStatus)
issue, err := h.Client.GetIssue(r.Context(), token, owner, repo, index)
if err != nil {
slog.Error("failed to get issue", "error", err, "owner", owner, "repo", repo, "index", index)
renderPage(w, r, "Issue Not Found", "issues",
`<h1>Issue Not Found</h1><p class="empty">Could not load the requested issue.</p>`)
return
}
renderPage(w, r, "Pull Requests", "pulls", content)
comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index)
if err != nil {
slog.Error("failed to get comments", "error", err)
comments = nil // non-fatal, render without comments
}
labels, err := h.Client.GetRepoLabels(r.Context(), token, owner, repo)
if err != nil {
slog.Error("failed to get repo labels", "error", err)
labels = nil
}
collaborators, err := h.Client.ListCollaborators(r.Context(), token, owner, repo)
if err != nil {
slog.Error("failed to get collaborators", "error", err)
collaborators = nil
}
// Render markdown body if present.
var renderedBody template.HTML
if issue.Body != "" {
rendered, err := h.Client.RenderMarkdown(r.Context(), token, issue.Body)
if err != nil {
slog.Warn("failed to render issue body markdown, using plain text", "error", err)
} else {
renderedBody = template.HTML(rendered)
}
}
// Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/issue_detail.html")
if err != nil {
slog.Error("failed to parse issue_detail template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
type templateData struct {
Issue *giteaclient.Issue
RenderedBody template.HTML
Comments []giteaclient.Comment
AvailableLabels []giteaclient.Label
Collaborators []string
}
data := templateData{
Issue: issue,
RenderedBody: renderedBody,
Comments: comments,
AvailableLabels: labels,
Collaborators: collaborators,
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute issue_detail template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
renderPage(w, r, fmt.Sprintf("Issue #%d", index), "issues", buf.String())
}
// PullDetail handles GET /pulls/{owner}/{repo}/{index}.
func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid PR index", http.StatusBadRequest)
return
}
pr, err := h.Client.GetPull(r.Context(), token, owner, repo, index)
if err != nil {
slog.Error("failed to get pull request", "error", err, "owner", owner, "repo", repo, "index", index)
renderPage(w, r, "PR Not Found", "pulls",
`<h1>Pull Request Not Found</h1><p class="empty">Could not load the requested pull request.</p>`)
return
}
// Render markdown body if present.
var renderedBody template.HTML
if pr.Body != "" {
rendered, err := h.Client.RenderMarkdown(r.Context(), token, pr.Body)
if err != nil {
slog.Warn("failed to render PR body markdown, using plain text", "error", err)
} else {
renderedBody = template.HTML(rendered)
}
}
// Fetch comments for this PR (Gitea uses the issues endpoint for PR comments).
comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index)
if err != nil {
slog.Warn("failed to fetch PR comments", "error", err, "owner", owner, "repo", repo, "index", index)
// Non-fatal: continue rendering without comments.
}
// Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil {
slog.Error("failed to parse pull_detail template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
type templateData struct {
Pull *giteaclient.PullRequest
RenderedBody template.HTML
Comments []giteaclient.Comment
}
data := templateData{
Pull: pr,
RenderedBody: renderedBody,
Comments: comments,
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute pull_detail template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
renderPage(w, r, fmt.Sprintf("PR #%d", index), "pulls", buf.String())
}
// NewIssue handles GET /issues/new — renders the create-issue form.
func (h *Handler) NewIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
repos, err := h.Client.ListOrgsAndRepos(r.Context(), token)
if err != nil {
slog.Error("failed to list repos for new issue form", "error", err)
renderPage(w, r, "New Issue", "issues",
`<h1>New Issue</h1><p class="empty">Error loading repositories.</p>`)
return
}
tmpl, err := template.ParseFiles("internal/templates/create_issue.html")
if err != nil {
slog.Error("failed to parse create_issue template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
type templateData struct {
Repos map[string][]giteaclient.Repo
}
data := templateData{Repos: repos}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute create_issue template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
renderPage(w, r, "New Issue", "issues", buf.String())
}
// NewIssueLabels handles GET /issues/new/labels — returns label checkboxes for a repo.
func (h *Handler) NewIssueLabels(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.URL.Query().Get("owner")
repo := r.URL.Query().Get("repo")
if owner == "" || repo == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<span class="empty">Select a repository first.</span>`)
return
}
labels, err := h.Client.GetRepoLabels(r.Context(), token, owner, repo)
if err != nil {
slog.Error("failed to fetch labels", "error", err, "owner", owner, "repo", repo)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<span class="empty">Error loading labels.</span>`)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if len(labels) == 0 {
fmt.Fprint(w, `<span class="empty">No labels available for this repository.</span>`)
return
}
for _, l := range labels {
fmt.Fprintf(w, `<label style="display:inline-block;margin:0.25rem 0.5rem 0.25rem 0;cursor:pointer;"><input type="checkbox" name="label_ids" value="%d" style="margin-right:0.25rem;"> <span class="label" style="color:#%s;border:1px solid #%s">%s</span></label>`,
l.ID, template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Color), template.HTMLEscapeString(l.Name))
}
}
// CreateIssue handles POST /issues.
@@ -342,14 +688,35 @@ 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")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `<span class="empty">owner, repo, and title are required</span>`)
return
}
http.Error(w, "owner, repo, and title are required", http.StatusBadRequest)
return
}
issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, 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) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, `<span class="empty">Failed to create issue. Please try again.</span>`)
return
}
http.Error(w, "failed to create issue", http.StatusInternalServerError)
return
}
@@ -410,6 +777,210 @@ 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)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := h.Client.CloseIssue(r.Context(), token, owner, repo, index); err != nil {
slog.Error("failed to close issue", "error", err, "owner", owner, "repo", repo, "index", index)
http.Error(w, "failed to close issue", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("HX-Redirect", fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index))
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// SetIssueState handles POST /issues/{owner}/{repo}/{index}/state.
func (h *Handler) SetIssueState(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
state := r.FormValue("state")
if state != "open" && state != "closed" {
http.Error(w, "state must be 'open' or 'closed'", http.StatusBadRequest)
return
}
if err := h.Client.SetIssueState(r.Context(), token, owner, repo, index, state); err != nil {
slog.Error("failed to set issue state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state)
http.Error(w, "failed to update issue state", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if state == "closed" {
fmt.Fprintf(w, `<span class="state-closed" id="issue-state">closed</span>
<button class="btn btn-secondary" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
} else {
fmt.Fprintf(w, `<span class="state-open" id="issue-state">open</span>
<button class="btn btn-danger" hx-post="/issues/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
}
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// SetPullState handles POST /pulls/{owner}/{repo}/{index}/state.
func (h *Handler) SetPullState(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid pull request index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
state := r.FormValue("state")
if state != "open" && state != "closed" {
http.Error(w, "state must be 'open' or 'closed'", http.StatusBadRequest)
return
}
if err := h.Client.SetIssueState(r.Context(), token, owner, repo, index, state); err != nil {
slog.Error("failed to set pull request state", "error", err, "owner", owner, "repo", repo, "index", index, "state", state)
http.Error(w, "failed to update pull request state", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if state == "closed" {
fmt.Fprintf(w, `<span class="state-closed" id="pull-state">closed</span>
<button class="btn btn-secondary" hx-post="/pulls/%s/%s/%d/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen PR</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
} else {
fmt.Fprintf(w, `<span class="state-open" id="pull-state">open</span>
<button class="btn btn-danger" hx-post="/pulls/%s/%s/%d/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close PR</button>`,
template.HTMLEscapeString(owner), template.HTMLEscapeString(repo), index)
}
return
}
http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// AddComment handles POST /issues/{owner}/{repo}/{index}/comment.
func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
body := r.FormValue("body")
if body == "" {
http.Error(w, "comment body is required", http.StatusBadRequest)
return
}
comment, err := h.Client.PostComment(r.Context(), token, owner, repo, index, body)
if err != nil {
slog.Error("failed to post comment", "error", err, "owner", owner, "repo", repo, "index", index)
http.Error(w, "failed to post comment", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="card comment">
<div class="card-meta">%s &middot; %s</div>
<div class="card-body">%s</div>
</div>`, template.HTMLEscapeString(comment.User), template.HTMLEscapeString(comment.CreatedAt), template.HTMLEscapeString(comment.Body))
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// SubmitReview handles POST /pulls/{owner}/{repo}/{index}/review.
func (h *Handler) SubmitReview(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
+48
View File
@@ -135,6 +135,54 @@ func TestSubmitReview_MissingEventType(t *testing.T) {
}
}
func TestSetIssueState_InvalidState(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/1/state", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestSetIssueState_InvalidIndex(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/abc/state", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestAddComment_EmptyBody(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/1/comments", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
+94 -11
View File
@@ -1,23 +1,29 @@
{{define "content"}}
<h1>Create Issue</h1>
<form hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div id="form-error" class="empty" style="display:none;"></div>
<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>
@@ -32,11 +38,88 @@
</form>
<script>
// Split owner/repo from select into hidden fields.
document.getElementById('repo-select').addEventListener('change', function() {
var parts = this.value.split('/');
document.getElementById('owner-input').value = parts[0] || '';
document.getElementById('repo-input').value = parts[1] || '';
(function() {
var repoSelect = document.getElementById('repo-select');
var ownerInput = document.getElementById('owner-input');
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 && val.indexOf('/') !== -1) {
var parts = val.split('/');
ownerInput.value = parts[0] || '';
repoInput.value = parts[1] || '';
} else {
ownerInput.value = '';
repoInput.value = '';
}
}
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) {
splitOwnerRepo();
if (!ownerInput.value || !repoInput.value) {
evt.preventDefault();
formError.textContent = 'Please select a repository before submitting.';
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';
});
// Show server-side errors inline on HTMX error responses.
document.getElementById('create-issue-form').addEventListener('htmx:responseError', function(evt) {
formError.textContent = evt.detail.xhr.responseText || 'An error occurred. Please try again.';
formError.style.display = 'block';
});
})();
</script>
{{end}}
+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}}
+36 -2
View File
@@ -3,19 +3,30 @@
<div class="card">
<div class="card-meta">
<span class="state-open">{{.Issue.State}}</span>
<span id="state-section">
{{if eq .Issue.State "closed"}}
<span class="state-closed" id="issue-state">{{.Issue.State}}</span>
<button class="btn btn-secondary" hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>
{{else}}
<span class="state-open" id="issue-state">{{.Issue.State}}</span>
<button class="btn btn-danger" hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>
{{end}}
</span>
<span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span>
{{range .Issue.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
</div>
{{if .Issue.Body}}
{{if .RenderedBody}}
<div class="card-body markdown-body">{{.RenderedBody}}</div>
{{else if .Issue.Body}}
<div class="card-body">{{.Issue.Body}}</div>
{{end}}
</div>
{{if .Comments}}
<h2>Comments</h2>
<div id="comments-list">
{{range .Comments}}
<div class="comment">
<div class="comment-header">
@@ -25,10 +36,33 @@
<div class="comment-body">{{.Body}}</div>
</div>
{{end}}
</div>
{{else}}
<div id="comments-list"></div>
{{end}}
<div class="card" style="margin-top:1rem;">
<h2>Add Comment</h2>
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/comments" hx-target="#comments-list" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
<textarea name="body" rows="4" placeholder="Write a comment..." required style="width:100%;margin-bottom:0.5rem;"></textarea>
<button type="submit" class="btn btn-primary" style="width:auto;padding:0.5rem 1rem;">Comment</button>
</form>
</div>
<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">
+37 -24
View File
@@ -1,25 +1,4 @@
{{define "content"}}
<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']">
<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']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select>
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Issues}}
<p class="empty">No issues found.</p>
{{else}}
<div id="issue-list">
{{define "cards"}}
{{range .Issues}}
<div class="card" hx-get="/issues/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">{{.Title}}</div>
@@ -29,16 +8,50 @@
<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}}
{{end}}
{{define "content"}}
<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'],[name='label']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
{{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}}
<p class="empty">{{.Error}}</p>
{{else if not .Issues}}
<p class="empty">No issues found.</p>
{{else}}
<div id="issue-list">
{{template "cards" .}}
</div>
{{end}}
{{end}}
+4
View File
@@ -13,6 +13,10 @@
<script src="/static/htmx.min.js"></script>
</head>
<body>
<header class="top-bar">
<span class="top-bar-title">Gitea Mobile</span>
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">&#8635;</button>
</header>
<div class="content" id="main-content">
{{template "content" .}}
</div>
+32 -2
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,7 +15,20 @@
<span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</div>
{{if .Pull.Body}}
<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}}
<div class="card-body">{{.Pull.Body}}</div>
{{end}}
</div>
@@ -44,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}}
+47 -19
View File
@@ -1,21 +1,4 @@
{{define "content"}}
<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">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Pulls}}
<p class="empty">No open pull requests found.</p>
{{else}}
<div id="pull-list">
{{define "cards"}}
{{range .Pulls}}
<div class="card" hx-get="/pulls/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">
@@ -29,10 +12,55 @@
{{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}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
{{end}}
{{define "content"}}
<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'],[name='label']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
{{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}}
<p class="empty">{{.Error}}</p>
{{else if not .Pulls}}
<p class="empty">No {{.SelectedState}} pull requests found.</p>
{{else}}
<div id="pull-list">
{{template "cards" .}}
</div>
{{end}}
{{end}}
+73 -3
View File
@@ -47,10 +47,53 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* Top bar */
.top-bar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-lg);
padding-top: max(var(--spacing-sm), env(safe-area-inset-top));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.top-bar-title {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
.refresh-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.4rem;
padding: var(--spacing-xs) var(--spacing-sm);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
line-height: 1;
border-radius: var(--radius-sm);
transition: color 0.15s ease, background 0.15s ease;
}
.refresh-btn:active {
color: var(--accent-blue);
background: var(--bg-tertiary);
}
.refresh-btn.htmx-request {
animation: spin 0.6s linear infinite;
pointer-events: none;
}
/* Content area */
.content {
padding: var(--spacing-lg);
padding-top: max(var(--spacing-lg), env(safe-area-inset-top));
padding-top: var(--spacing-lg);
max-width: 640px;
margin: 0 auto;
}
@@ -376,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;
@@ -444,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;
}
}