Compare commits

...

16 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
9 changed files with 305 additions and 29 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
+107 -7
View File
@@ -107,6 +107,7 @@ type PullRequest struct {
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)
} }
@@ -913,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 {
+88 -2
View File
@@ -55,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{
@@ -249,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
@@ -259,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
@@ -268,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 {
@@ -277,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."
@@ -338,6 +356,9 @@ func (h *Handler) ListPulls(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
@@ -348,6 +369,8 @@ func (h *Handler) ListPulls(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
@@ -357,6 +380,8 @@ func (h *Handler) ListPulls(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 {
@@ -365,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, selectedState, 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."
@@ -377,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)
}
} }
} }
@@ -852,6 +891,53 @@ func (h *Handler) SetIssueState(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) 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)
+40 -8
View File
@@ -6,16 +6,15 @@
<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>
@@ -45,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] || '';
@@ -57,11 +63,31 @@
} }
} }
repoSelect.addEventListener('change', function() { var debounceTimer = null;
repoSelect.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
splitOwnerRepo(); splitOwnerRepo();
var labelSection = document.getElementById('label-section'); var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list'); var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.value) { if (ownerInput.value && repoInput.value && validRepos[repoSelect.value]) {
labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
} 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>'; labelList.innerHTML = '<span class="empty">Loading labels...</span>';
labelSection.style.display = 'block'; labelSection.style.display = 'block';
htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'}); htmx.ajax('GET', '/issues/new/labels?owner=' + encodeURIComponent(ownerInput.value) + '&repo=' + encodeURIComponent(repoInput.value), {target: '#label-list', swap: 'innerHTML'});
@@ -80,6 +106,12 @@
formError.style.display = 'block'; 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';
}); });
+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}}
+12 -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}}
+19 -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}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this"> <div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&label={{.SelectedLabel}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div> <div class="spinner htmx-indicator"></div>
</div> </div>
{{end}} {{end}}
@@ -27,16 +33,25 @@
<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" hx-include="[name='state']"> <select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state'],[name='label']">
<option value="">All orgs</option> <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="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']"> {{if .Repos}}
<select name="repo" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='state'],[name='label']">
<option value="">All repos</option>
{{range .Repos}}<option value="{{.}}" {{if eq . $.SelectedRepo}}selected{{end}}>{{.}}</option>{{end}}
</select>
{{end}}
<select name="state" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org'],[name='repo'],[name='label']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option> <option value="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="/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}}
+23
View File
@@ -419,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;