Compare commits

..

30 Commits

Author SHA1 Message Date
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 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
14 changed files with 792 additions and 47 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
go-version: '1.22' go-version: '1.22'
- name: Run tests - name: Run tests
run: go test ./... run: go test -race ./...
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+1 -1
View File
@@ -1,7 +1,7 @@
# Stage 1: Build # Stage 1: Build
FROM golang:1.22-alpine AS builder FROM golang:1.22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /gitea-mobile ./cmd/server
+165 -12
View File
@@ -105,8 +105,9 @@ type PullRequest struct {
Deletions int `json:"deletions"` Deletions int `json:"deletions"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
RepoOwner string `json:"-"` // populated after fetch RepoOwner string `json:"-"` // populated after fetch
RepoName 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. // TriageItem represents an item in the triage queue.
@@ -325,7 +326,9 @@ type PaginatedPulls struct {
// ListAllIssues fetches issues across all repos in the given orgs, // ListAllIssues fetches issues across all repos in the given orgs,
// using concurrent requests with a semaphore. Results are paginated. // using concurrent requests with a semaphore. Results are paginated.
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedIssues, error) { // The label parameter filters issues by label name (empty string means no filter).
// The repoFilter parameter narrows results to a single repo name (empty means all repos).
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedIssues, error) {
if state == "" { if state == "" {
state = "open" state = "open"
} }
@@ -333,7 +336,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
page = 1 page = 1
} }
cacheKey := fmt.Sprintf("issues-%s-%s", state, strings.Join(orgs, ",")) cacheKey := fmt.Sprintf("issues-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
var allIssues []Issue var allIssues []Issue
if cached, ok := c.getFromCache(cacheKey); ok { if cached, ok := c.getFromCache(cacheKey); ok {
allIssues = cached.([]Issue) allIssues = cached.([]Issue)
@@ -348,6 +351,17 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
allRepos = append(allRepos, repos...) 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. // Fan out issue fetching across repos.
var mu sync.Mutex var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent) sem := make(chan struct{}, c.maxConcurrent)
@@ -362,6 +376,9 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
defer func() { <-sem }() defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/issues?state=%s&type=issues&limit=50", r.FullName, state) 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) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil { if err != nil {
mu.Lock() mu.Lock()
@@ -424,8 +441,9 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
} }
// ListAllPullRequests fetches PRs across all repos in the given orgs. // ListAllPullRequests fetches PRs across all repos in the given orgs.
// Results are paginated. // Results are paginated. The label parameter filters PRs by label name.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedPulls, error) { // The repoFilter parameter narrows results to a single repo name.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int, label string, repoFilter string) (PaginatedPulls, error) {
if state == "" { if state == "" {
state = "open" state = "open"
} }
@@ -433,7 +451,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
page = 1 page = 1
} }
cacheKey := fmt.Sprintf("pulls-%s-%s", state, strings.Join(orgs, ",")) cacheKey := fmt.Sprintf("pulls-%s-%s-%s-%s", state, strings.Join(orgs, ","), label, repoFilter)
var allPRs []PullRequest var allPRs []PullRequest
if cached, ok := c.getFromCache(cacheKey); ok { if cached, ok := c.getFromCache(cacheKey); ok {
allPRs = cached.([]PullRequest) allPRs = cached.([]PullRequest)
@@ -447,6 +465,17 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
allRepos = append(allRepos, repos...) allRepos = append(allRepos, repos...)
} }
// Filter to a single repo if specified.
if repoFilter != "" {
var filtered []Repo
for _, r := range allRepos {
if r.Name == repoFilter {
filtered = append(filtered, r)
}
}
allRepos = filtered
}
var mu sync.Mutex var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent) sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -460,6 +489,9 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
defer func() { <-sem }() defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/pulls?state=%s&limit=50", r.FullName, state) 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) resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil { if err != nil {
mu.Lock() mu.Lock()
@@ -524,7 +556,7 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
// Collect all open issues across all pages. // Collect all open issues across all pages.
var issues []Issue var issues []Issue
for page := 1; ; page++ { for page := 1; ; page++ {
result, err := c.ListAllIssues(ctx, token, orgs, "open", page) result, err := c.ListAllIssues(ctx, token, orgs, "open", page, "", "")
if err != nil { if err != nil {
return nil, fmt.Errorf("fetching issues for triage: %w", err) return nil, fmt.Errorf("fetching issues for triage: %w", err)
} }
@@ -537,7 +569,7 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
// Collect all open PRs across all pages. // Collect all open PRs across all pages.
var prs []PullRequest var prs []PullRequest
for page := 1; ; page++ { for page := 1; ; page++ {
result, err := c.ListAllPullRequests(ctx, token, orgs, "open", page) result, err := c.ListAllPullRequests(ctx, token, orgs, "open", page, "", "")
if err != nil { if err != nil {
return nil, fmt.Errorf("fetching PRs for triage: %w", err) return nil, fmt.Errorf("fetching PRs for triage: %w", err)
} }
@@ -753,6 +785,49 @@ func (c *Client) ApplyLabel(ctx context.Context, token, owner, repo string, inde
return nil 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. // 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 { func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, index int64, reviewType, body string) error {
payload := map[string]interface{}{ payload := map[string]interface{}{
@@ -778,15 +853,20 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in
// CloseIssue closes an issue by setting its state to "closed". // CloseIssue closes an issue by setting its state to "closed".
func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error { func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error {
payload, err := json.Marshal(map[string]string{"state": "closed"}) 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 { if err != nil {
return fmt.Errorf("marshaling close request: %w", err) return fmt.Errorf("marshaling state change: %w", err)
} }
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index) path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload))) resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload)))
if err != nil { if err != nil {
return fmt.Errorf("closing issue: %w", err) return fmt.Errorf("setting issue state: %w", err)
} }
resp.Body.Close() resp.Body.Close()
@@ -794,6 +874,11 @@ func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, inde
return nil 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. // 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) { 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}) payload, err := json.Marshal(map[string]string{"body": body})
@@ -860,6 +945,74 @@ func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string
return string(rendered), nil 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). // priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int { func priorityScore(labels []string) int {
for _, l := range labels { for _, l := range labels {
+70
View File
@@ -241,6 +241,76 @@ func TestCloseIssue(t *testing.T) {
} }
} }
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) { func TestPostComment(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
+271 -15
View File
@@ -39,9 +39,13 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Issues. // Issues.
mux.HandleFunc("GET /issues", h.ListIssues) mux.HandleFunc("GET /issues", h.ListIssues)
mux.HandleFunc("GET /issues/new", h.NewIssue) mux.HandleFunc("GET /issues/new", h.NewIssue)
mux.HandleFunc("GET /issues/new/labels", h.NewIssueLabels)
mux.HandleFunc("POST /issues", h.CreateIssue) mux.HandleFunc("POST /issues", h.CreateIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/assignees", h.AssignIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/close", h.CloseIssue) 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) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment)
// Issue detail. // Issue detail.
@@ -51,6 +55,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /pulls", h.ListPulls) mux.HandleFunc("GET /pulls", h.ListPulls)
mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail) 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}/review", h.SubmitReview)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/state", h.SetPullState)
// Settings (handled separately for auth bypass). // Settings (handled separately for auth bypass).
settingsHandler := &SettingsHandler{ settingsHandler := &SettingsHandler{
@@ -115,6 +120,10 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
</head> </head>
<body> <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"> <div class="content" id="main-content">
{{.Content}} {{.Content}}
</div> </div>
@@ -182,18 +191,30 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
orgs := h.getUserOrgs(r) orgs := h.getUserOrgs(r)
selectedOrg := r.URL.Query().Get("org")
type dashboardData struct { type dashboardData struct {
Items []giteaclient.TriageItem Items []giteaclient.TriageItem
Error string Orgs []string
SelectedOrg string
Error string
} }
var data dashboardData data := dashboardData{
Orgs: orgs,
SelectedOrg: selectedOrg,
}
if len(orgs) == 0 { if len(orgs) == 0 {
data.Error = "No organizations found. Check your token permissions." data.Error = "No organizations found. Check your token permissions."
} else { } else {
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs) // Determine which orgs to query.
queryOrgs := orgs
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
queue, err := h.Client.GetTriageQueue(r.Context(), token, queryOrgs)
if err != nil { if err != nil {
slog.Error("failed to get triage queue", "error", err) slog.Error("failed to get triage queue", "error", err)
data.Error = "Error loading triage queue." data.Error = "Error loading triage queue."
@@ -229,6 +250,9 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs []string Orgs []string
SelectedOrg string SelectedOrg string
SelectedState string SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool HasMore bool
NextPage int NextPage int
Error string Error string
@@ -239,6 +263,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if selectedState == "" { if selectedState == "" {
selectedState = "open" selectedState = "open"
} }
selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
page, _ := strconv.Atoi(r.URL.Query().Get("page")) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -248,6 +274,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames, Orgs: orgNames,
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState, SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -257,9 +285,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames queryOrgs := orgNames
if selectedOrg != "" { if selectedOrg != "" {
queryOrgs = []string{selectedOrg} queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
} }
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page) result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil { if err != nil {
slog.Error("failed to list issues", "error", err) slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues." data.Error = "Error loading issues."
@@ -314,23 +352,36 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
orgNames := h.getUserOrgs(r) orgNames := h.getUserOrgs(r)
type pullsData struct { type pullsData struct {
Pulls []giteaclient.PullRequest Pulls []giteaclient.PullRequest
Orgs []string Orgs []string
SelectedOrg string SelectedOrg string
HasMore bool SelectedState string
NextPage int SelectedLabel string
Error string SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
} }
selectedOrg := r.URL.Query().Get("org") 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")) page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 { if page < 1 {
page = 1 page = 1
} }
data := pullsData{ data := pullsData{
Orgs: orgNames, Orgs: orgNames,
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -339,9 +390,19 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames queryOrgs := orgNames
if selectedOrg != "" { if selectedOrg != "" {
queryOrgs = []string{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, "open", page) result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil { if err != nil {
slog.Error("failed to list pull requests", "error", err) slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests." data.Error = "Error loading pull requests."
@@ -351,6 +412,10 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
if result.HasMore { if result.HasMore {
data.NextPage = page + 1 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)
}
} }
} }
@@ -423,6 +488,12 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
labels = nil 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. // Render markdown body if present.
var renderedBody template.HTML var renderedBody template.HTML
if issue.Body != "" { if issue.Body != "" {
@@ -447,6 +518,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
RenderedBody template.HTML RenderedBody template.HTML
Comments []giteaclient.Comment Comments []giteaclient.Comment
AvailableLabels []giteaclient.Label AvailableLabels []giteaclient.Label
Collaborators []string
} }
data := templateData{ data := templateData{
@@ -454,6 +526,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
RenderedBody: renderedBody, RenderedBody: renderedBody,
Comments: comments, Comments: comments,
AvailableLabels: labels, AvailableLabels: labels,
Collaborators: collaborators,
} }
var buf strings.Builder var buf strings.Builder
@@ -498,6 +571,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
} }
} }
// Fetch comments for this PR (Gitea uses the issues endpoint for PR comments).
comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index)
if err != nil {
slog.Warn("failed to fetch PR comments", "error", err, "owner", owner, "repo", repo, "index", index)
// Non-fatal: continue rendering without comments.
}
// Build the content HTML using the template. // Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html") tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil { if err != nil {
@@ -509,11 +589,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
type templateData struct { type templateData struct {
Pull *giteaclient.PullRequest Pull *giteaclient.PullRequest
RenderedBody template.HTML RenderedBody template.HTML
Comments []giteaclient.Comment
} }
data := templateData{ data := templateData{
Pull: pr, Pull: pr,
RenderedBody: renderedBody, RenderedBody: renderedBody,
Comments: comments,
} }
var buf strings.Builder var buf strings.Builder
@@ -561,6 +643,38 @@ func (h *Handler) NewIssue(w http.ResponseWriter, r *http.Request) {
renderPage(w, r, "New Issue", "issues", buf.String()) 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. // CreateIssue handles POST /issues.
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
@@ -574,6 +688,15 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title") title := r.FormValue("title")
body := r.FormValue("body") 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 owner == "" || repo == "" || title == "" {
if isHTMX(r) { if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -585,7 +708,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
slog.Error("failed to create issue", "error", err) slog.Error("failed to create issue", "error", err)
if isHTMX(r) { if isHTMX(r) {
@@ -654,6 +777,45 @@ func (h *Handler) ApplyLabels(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) 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. // CloseIssue handles POST /issues/{owner}/{repo}/{index}/close.
func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) { func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
@@ -682,6 +844,100 @@ func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
} }
// 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. // AddComment handles POST /issues/{owner}/{repo}/{index}/comment.
func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) { func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) {
token := getToken(r) 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 { func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr) return len(s) >= len(substr) && searchString(s, substr)
} }
+56 -7
View File
@@ -6,20 +6,24 @@
<form id="create-issue-form" hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content"> <form id="create-issue-form" hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div class="form-group"> <div class="form-group">
<label for="repo-select">Repository</label> <label for="repo-select">Repository</label>
<select id="repo-select" name="owner_repo" required> <input list="repo-options" id="repo-select" name="owner_repo"
<option value="">Select a repository...</option> placeholder="Type to search repositories..." required autocomplete="off">
<datalist id="repo-options">
{{range $org, $repos := .Repos}} {{range $org, $repos := .Repos}}
<optgroup label="{{$org}}">
{{range $repos}} {{range $repos}}
<option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option> <option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option>
{{end}} {{end}}
</optgroup>
{{end}} {{end}}
</select> </datalist>
<input type="hidden" name="owner" id="owner-input"> <input type="hidden" name="owner" id="owner-input">
<input type="hidden" name="repo" id="repo-input"> <input type="hidden" name="repo" id="repo-input">
</div> </div>
<div class="form-group" id="label-section" style="display:none;">
<label>Labels</label>
<div id="label-list"></div>
</div>
<div class="form-group"> <div class="form-group">
<label for="title">Title</label> <label for="title">Title</label>
<input type="text" id="title" name="title" placeholder="Issue title" required> <input type="text" id="title" name="title" placeholder="Issue title" required>
@@ -40,9 +44,16 @@
var repoInput = document.getElementById('repo-input'); var repoInput = document.getElementById('repo-input');
var formError = document.getElementById('form-error'); 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() { function splitOwnerRepo() {
var val = repoSelect.value; var val = repoSelect.value;
if (val) { if (val && val.indexOf('/') !== -1) {
var parts = val.split('/'); var parts = val.split('/');
ownerInput.value = parts[0] || ''; ownerInput.value = parts[0] || '';
repoInput.value = parts[1] || ''; repoInput.value = parts[1] || '';
@@ -52,7 +63,39 @@
} }
} }
repoSelect.addEventListener('change', splitOwnerRepo); var debounceTimer = null;
repoSelect.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
} else {
labelSection.style.display = 'none';
labelList.innerHTML = '';
}
}, 300);
});
// Also handle the change event for when a datalist option is selected directly.
repoSelect.addEventListener('change', function() {
clearTimeout(debounceTimer);
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
} else {
labelSection.style.display = 'none';
labelList.innerHTML = '';
}
});
// Validate before HTMX submit. // Validate before HTMX submit.
document.getElementById('create-issue-form').addEventListener('htmx:configRequest', function(evt) { document.getElementById('create-issue-form').addEventListener('htmx:configRequest', function(evt) {
@@ -63,6 +106,12 @@
formError.style.display = 'block'; formError.style.display = 'block';
return false; 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'; formError.style.display = 'none';
}); });
+11
View File
@@ -1,6 +1,17 @@
{{define "content"}} {{define "content"}}
<h1>Dashboard</h1> <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}} {{if .Error}}
<p class="empty">{{.Error}}</p> <p class="empty">{{.Error}}</p>
{{else if not .Items}} {{else if not .Items}}
+33 -1
View File
@@ -3,7 +3,15 @@
<div class="card"> <div class="card">
<div class="card-meta"> <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> <span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span>
{{range .Issue.Labels}} {{range .Issue.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
@@ -18,6 +26,7 @@
{{if .Comments}} {{if .Comments}}
<h2>Comments</h2> <h2>Comments</h2>
<div id="comments-list">
{{range .Comments}} {{range .Comments}}
<div class="comment"> <div class="comment">
<div class="comment-header"> <div class="comment-header">
@@ -27,10 +36,33 @@
<div class="comment-body">{{.Body}}</div> <div class="comment-body">{{.Body}}</div>
</div> </div>
{{end}} {{end}}
</div>
{{else}}
<div id="comments-list"></div>
{{end}} {{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;"> <div class="card" style="margin-top:1rem;">
<h2>Actions</h2> <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;"> <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;"> <div class="filter-bar" style="margin-bottom:0.5rem;">
<select name="label_id"> <select name="label_id">
+13 -4
View File
@@ -8,13 +8,13 @@
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}} {{end}}
{{if .Assignee}} {{if .Assignee}}
<span>{{.Assignee.Login}}</span> <img src="{{.Assignee.AvatarURL}}" alt="{{.Assignee.Login}}" class="avatar" title="Assigned to {{.Assignee.Login}}">
{{end}} {{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
{{if .HasMore}} {{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 class="spinner htmx-indicator"></div>
</div> </div>
{{end}} {{end}}
@@ -24,16 +24,25 @@
<h1>Issues</h1> <h1>Issues</h1>
<div class="filter-bar"> <div class="filter-bar">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']"> <select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state'],[name='label']">
<option value="">All orgs</option> <option value="">All orgs</option>
{{range .Orgs}} {{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option> <option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}} {{end}}
</select> </select>
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']"> {{if .Repos}}
<select name="repo" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='label']">
<option value="">All repos</option>
{{range .Repos}}<option value="{{.}}" {{if eq . $.SelectedRepo}}selected{{end}}>{{.}}</option>{{end}}
</select>
{{end}}
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='repo'],[name='label']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option> <option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option> <option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select> </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> </div>
{{if .Error}} {{if .Error}}
+4
View File
@@ -13,6 +13,10 @@
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
</head> </head>
<body> <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"> <div class="content" id="main-content">
{{template "content" .}} {{template "content" .}}
</div> </div>
+29 -1
View File
@@ -4,7 +4,7 @@
<div class="card"> <div class="card">
<div class="card-meta"> <div class="card-meta">
<span class="type-badge type-pull">PR</span> <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> <span>{{.Pull.RepoOwner}}/{{.Pull.RepoName}} #{{.Pull.Number}}</span>
{{range .Pull.Labels}} {{range .Pull.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
@@ -15,6 +15,17 @@
<span class="diff-del">-{{.Pull.Deletions}}</span> <span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}} {{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</div> </div>
<div class="card-meta" style="margin-top:0.5rem;">
<span id="state-section">
{{if eq .Pull.State "closed"}}
<span class="state-closed" id="pull-state">{{.Pull.State}}</span>
<button class="btn btn-secondary" hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen PR</button>
{{else}}
<span class="state-open" id="pull-state">{{.Pull.State}}</span>
<button class="btn btn-danger" hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close PR</button>
{{end}}
</span>
</div>
{{if .RenderedBody}} {{if .RenderedBody}}
<div class="card-body markdown-body">{{.RenderedBody}}</div> <div class="card-body markdown-body">{{.RenderedBody}}</div>
{{else if .Pull.Body}} {{else if .Pull.Body}}
@@ -46,4 +57,21 @@
<button type="submit" class="btn btn-primary">Submit Review</button> <button type="submit" class="btn btn-primary">Submit Review</button>
</form> </form>
</div> </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}} {{end}}
+23 -4
View File
@@ -12,12 +12,18 @@
{{end}} {{end}}
<span class="diff-add">+{{.Additions}}</span> <span class="diff-add">+{{.Additions}}</span>
<span class="diff-del">-{{.Deletions}}</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>
</div> </div>
{{end}} {{end}}
{{if .HasMore}} {{if .HasMore}}
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this"> <div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div> <div class="spinner htmx-indicator"></div>
</div> </div>
{{end}} {{end}}
@@ -27,18 +33,31 @@
<h1>Pull Requests</h1> <h1>Pull Requests</h1>
<div class="filter-bar"> <div class="filter-bar">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true"> <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> <option value="">All orgs</option>
{{range .Orgs}} {{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option> <option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}} {{end}}
</select> </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> </div>
{{if .Error}} {{if .Error}}
<p class="empty">{{.Error}}</p> <p class="empty">{{.Error}}</p>
{{else if not .Pulls}} {{else if not .Pulls}}
<p class="empty">No open pull requests found.</p> <p class="empty">No {{.SelectedState}} pull requests found.</p>
{{else}} {{else}}
<div id="pull-list"> <div id="pull-list">
{{template "cards" .}} {{template "cards" .}}
+67 -1
View File
@@ -47,10 +47,53 @@ body {
-moz-osx-font-smoothing: grayscale; -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 area */
.content { .content {
padding: var(--spacing-lg); padding: var(--spacing-lg);
padding-top: max(var(--spacing-lg), env(safe-area-inset-top)); padding-top: var(--spacing-lg);
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
} }
@@ -376,6 +419,29 @@ a:active {
vertical-align: middle; 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 state */
.empty { .empty {
text-align: center; text-align: center;