Compare commits

...

34 Commits

Author SHA1 Message Date
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 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
13 changed files with 1362 additions and 220 deletions
+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
+382 -144
View File
@@ -308,173 +308,274 @@ 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
// 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)
}
allRepos = append(allRepos, repos...)
}
// Fan out issue fetching across repos.
var allIssues []Issue
var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
var firstErr error
for _, repo := range allRepos {
wg.Add(1)
go func(r Repo) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/issues?state=open&type=issues&limit=50", r.FullName)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("fetching issues for %s: %w", r.FullName, err)
}
mu.Unlock()
return
}
defer resp.Body.Close()
var issues []Issue
if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("decoding issues for %s: %w", r.FullName, err)
}
mu.Unlock()
return
}
// Tag each issue with repo info.
for i := range issues {
issues[i].RepoOwner = r.Owner.Login
issues[i].RepoName = r.Name
}
mu.Lock()
allIssues = append(allIssues, issues...)
mu.Unlock()
}(repo)
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
// Sort by updated time, newest first.
sort.Slice(allIssues, func(i, j int) bool {
return allIssues[i].UpdatedAt.After(allIssues[j].UpdatedAt)
})
c.setCache(cacheKey, allIssues)
return allIssues, nil
// PaginatedIssues holds a page of issues along with pagination metadata.
type PaginatedIssues struct {
Issues []Issue
HasMore bool
}
// 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, ","))
// 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 {
return cached.([]PullRequest), nil
}
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)
}
allRepos = append(allRepos, repos...)
}
var allPRs []PullRequest
var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
var firstErr error
for _, repo := range allRepos {
wg.Add(1)
go func(r Repo) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/pulls?state=open&limit=50", r.FullName)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
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 {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("fetching PRs for %s: %w", r.FullName, 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)
}
mu.Unlock()
return
}
defer resp.Body.Close()
allRepos = filtered
}
var prs []PullRequest
if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("decoding PRs for %s: %w", r.FullName, err)
// Fan out issue fetching across repos.
var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
var firstErr error
for _, repo := range allRepos {
wg.Add(1)
go func(r Repo) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/issues?state=%s&type=issues&limit=50", r.FullName, state)
if label != "" {
path += "&labels=" + label
}
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("fetching issues for %s: %w", r.FullName, err)
}
mu.Unlock()
return
}
defer resp.Body.Close()
var issues []Issue
if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("decoding issues for %s: %w", r.FullName, err)
}
mu.Unlock()
return
}
// Tag each issue with repo info.
for i := range issues {
issues[i].RepoOwner = r.Owner.Login
issues[i].RepoName = r.Name
}
mu.Lock()
allIssues = append(allIssues, issues...)
mu.Unlock()
return
}
}(repo)
}
for i := range prs {
prs[i].RepoOwner = r.Owner.Login
prs[i].RepoName = r.Name
}
wg.Wait()
mu.Lock()
allPRs = append(allPRs, prs...)
mu.Unlock()
}(repo)
if firstErr != nil {
return PaginatedIssues{}, firstErr
}
// Sort by updated time, newest first.
sort.Slice(allIssues, func(i, j int) bool {
return allIssues[i].UpdatedAt.After(allIssues[j].UpdatedAt)
})
c.setCache(cacheKey, allIssues)
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
// 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)
}
sort.Slice(allPRs, func(i, j int) bool {
return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt)
})
return PaginatedIssues{Issues: allIssues[start:end], HasMore: hasMore}, nil
}
c.setCache(cacheKey, allPRs)
return allPRs, 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 PaginatedPulls{}, 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
}
var mu sync.Mutex
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
var firstErr error
for _, repo := range allRepos {
wg.Add(1)
go func(r Repo) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
path := fmt.Sprintf("/repos/%s/pulls?state=%s&limit=50", r.FullName, state)
if label != "" {
path += "&labels=" + label
}
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("fetching PRs for %s: %w", r.FullName, err)
}
mu.Unlock()
return
}
defer resp.Body.Close()
var prs []PullRequest
if err := json.NewDecoder(resp.Body).Decode(&prs); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("decoding PRs for %s: %w", r.FullName, err)
}
mu.Unlock()
return
}
for i := range prs {
prs[i].RepoOwner = r.Owner.Login
prs[i].RepoName = r.Name
}
mu.Lock()
allPRs = append(allPRs, prs...)
mu.Unlock()
}(repo)
}
wg.Wait()
if firstErr != nil {
return PaginatedPulls{}, firstErr
}
sort.Slice(allPRs, func(i, j int) bool {
return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt)
})
c.setCache(cacheKey, allPRs)
}
// 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)
if err != nil {
return nil, fmt.Errorf("fetching issues for triage: %w", err)
// 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)
if err != nil {
return nil, fmt.Errorf("fetching PRs for triage: %w", err)
// 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
@@ -683,6 +784,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{}{
@@ -706,6 +850,100 @@ 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
}
// priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int {
for _, l := range labels {
+161
View File
@@ -201,6 +201,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++ {
+472 -17
View File
@@ -38,8 +38,15 @@ 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)
@@ -48,6 +55,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /pulls", h.ListPulls)
mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/state", h.SetPullState)
// Settings (handled separately for auth bypass).
settingsHandler := &SettingsHandler{
@@ -112,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>
@@ -179,18 +191,30 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
orgs := h.getUserOrgs(r)
selectedOrg := r.URL.Query().Get("org")
type dashboardData struct {
Items []giteaclient.TriageItem
Error string
Items []giteaclient.TriageItem
Orgs []string
SelectedOrg string
Error string
}
var data dashboardData
data := dashboardData{
Orgs: orgs,
SelectedOrg: selectedOrg,
}
if len(orgs) == 0 {
data.Error = "No organizations found. Check your token permissions."
} else {
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
// Determine which orgs to query.
queryOrgs := orgs
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
queue, err := h.Client.GetTriageQueue(r.Context(), token, queryOrgs)
if err != nil {
slog.Error("failed to get triage queue", "error", err)
data.Error = "Error loading triage queue."
@@ -226,6 +250,9 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
@@ -236,11 +263,19 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if selectedState == "" {
selectedState = "open"
}
selectedLabel := r.URL.Query().Get("label")
selectedRepo := r.URL.Query().Get("repo")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
data := issuesData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
@@ -250,17 +285,50 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
}
issues, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs)
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues."
} else {
data.Issues = issues
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
}
tmpl, err := template.ParseFiles("internal/templates/issues.html")
if err != nil {
slog.Error("failed to parse issues template", "error", err)
@@ -284,17 +352,36 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
orgNames := h.getUserOrgs(r)
type pullsData struct {
Pulls []giteaclient.PullRequest
Orgs []string
SelectedOrg string
Error string
Pulls []giteaclient.PullRequest
Orgs []string
SelectedOrg string
SelectedState string
SelectedLabel string
SelectedRepo string
Repos []string
HasMore bool
NextPage int
Error string
}
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,
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
SelectedLabel: selectedLabel,
SelectedRepo: selectedRepo,
}
if len(orgNames) == 0 {
@@ -303,17 +390,50 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
// Populate repo list for the selected org.
repos, err := h.Client.ListOrgRepos(r.Context(), token, selectedOrg)
if err != nil {
slog.Warn("failed to list repos for org filter", "error", err, "org", selectedOrg)
} else {
for _, repo := range repos {
data.Repos = append(data.Repos, repo.Name)
}
}
}
prs, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs)
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page, selectedLabel, selectedRepo)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
} else {
data.Pulls = prs
data.Pulls = result.Pulls
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/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
}
tmpl, err := template.ParseFiles("internal/templates/pulls.html")
if err != nil {
slog.Error("failed to parse pulls template", "error", err)
@@ -364,6 +484,23 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
labels = nil
}
collaborators, err := h.Client.ListCollaborators(r.Context(), token, owner, repo)
if err != nil {
slog.Error("failed to get collaborators", "error", err)
collaborators = nil
}
// Render markdown body if present.
var renderedBody template.HTML
if issue.Body != "" {
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 {
@@ -374,14 +511,18 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
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
@@ -415,6 +556,24 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
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 {
@@ -424,11 +583,15 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
}
type templateData struct {
Pull *giteaclient.PullRequest
Pull *giteaclient.PullRequest
RenderedBody template.HTML
Comments []giteaclient.Comment
}
data := templateData{
Pull: pr,
Pull: pr,
RenderedBody: renderedBody,
Comments: comments,
}
var buf strings.Builder
@@ -441,6 +604,73 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
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.
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
@@ -454,14 +684,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
}
@@ -522,6 +773,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)
}
+95 -12
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">
+36 -23
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>
@@ -35,10 +14,44 @@
</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}}
+40 -18
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">
@@ -33,6 +16,45 @@
</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}}
+44 -1
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;
}