Compare commits

..

1 Commits

Author SHA1 Message Date
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
5 changed files with 39 additions and 76 deletions
+7 -30
View File
@@ -325,8 +325,7 @@ 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.
// 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) (PaginatedIssues, error) {
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int, repoFilter string) (PaginatedIssues, error) {
if state == "" { if state == "" {
state = "open" state = "open"
} }
@@ -334,7 +333,7 @@ func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string,
page = 1 page = 1
} }
cacheKey := fmt.Sprintf("issues-%s-%s-%s", state, strings.Join(orgs, ","), repoFilter) cacheKey := fmt.Sprintf("issues-%s-%s", state, strings.Join(orgs, ","))
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)
@@ -349,17 +348,6 @@ 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)
@@ -436,8 +424,8 @@ 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. The repoFilter parameter narrows results to a single repo name. // Results are paginated.
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int, repoFilter string) (PaginatedPulls, error) { func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedPulls, error) {
if state == "" { if state == "" {
state = "open" state = "open"
} }
@@ -445,7 +433,7 @@ func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []s
page = 1 page = 1
} }
cacheKey := fmt.Sprintf("pulls-%s-%s-%s", state, strings.Join(orgs, ","), repoFilter) cacheKey := fmt.Sprintf("pulls-%s-%s", state, strings.Join(orgs, ","))
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)
@@ -459,17 +447,6 @@ 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
@@ -547,7 +524,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)
} }
@@ -560,7 +537,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)
} }
+11 -30
View File
@@ -249,8 +249,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs []string Orgs []string
SelectedOrg string SelectedOrg string
SelectedState string SelectedState string
SelectedRepo string
Repos []string
HasMore bool HasMore bool
NextPage int NextPage int
Error string Error string
@@ -261,7 +259,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
if selectedState == "" { if selectedState == "" {
selectedState = "open" selectedState = "open"
} }
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
@@ -271,7 +268,6 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames, Orgs: orgNames,
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState, SelectedState: selectedState,
SelectedRepo: selectedRepo,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -281,19 +277,9 @@ 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, selectedRepo) result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page)
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."
@@ -352,8 +338,6 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs []string Orgs []string
SelectedOrg string SelectedOrg string
SelectedState string SelectedState string
SelectedRepo string
Repos []string
HasMore bool HasMore bool
NextPage int NextPage int
Error string Error string
@@ -364,7 +348,6 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
if selectedState == "" { if selectedState == "" {
selectedState = "open" selectedState = "open"
} }
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
@@ -374,7 +357,6 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Orgs: orgNames, Orgs: orgNames,
SelectedOrg: selectedOrg, SelectedOrg: selectedOrg,
SelectedState: selectedState, SelectedState: selectedState,
SelectedRepo: selectedRepo,
} }
if len(orgNames) == 0 { if len(orgNames) == 0 {
@@ -383,19 +365,9 @@ 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, selectedRepo) result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page)
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."
@@ -560,6 +532,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
} }
} }
// Fetch comments for this PR (Gitea uses the issues endpoint for PR comments).
comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index)
if err != nil {
slog.Warn("failed to fetch PR comments", "error", err, "owner", owner, "repo", repo, "index", index)
// Non-fatal: continue rendering without comments.
}
// Build the content HTML using the template. // Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html") tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil { if err != nil {
@@ -571,11 +550,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
type templateData struct { type templateData struct {
Pull *giteaclient.PullRequest Pull *giteaclient.PullRequest
RenderedBody template.HTML RenderedBody template.HTML
Comments []giteaclient.Comment
} }
data := templateData{ data := templateData{
Pull: pr, Pull: pr,
RenderedBody: renderedBody, RenderedBody: renderedBody,
Comments: comments,
} }
var buf strings.Builder var buf strings.Builder
+2 -8
View File
@@ -14,7 +14,7 @@
</div> </div>
{{end}} {{end}}
{{if .HasMore}} {{if .HasMore}}
<div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this"> <div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" 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}}
@@ -30,13 +30,7 @@
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option> <option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}} {{end}}
</select> </select>
{{if .Repos}} <select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']">
<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']">
<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']">
<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>
+17
View File
@@ -46,4 +46,21 @@
<button type="submit" class="btn btn-primary">Submit Review</button> <button type="submit" class="btn btn-primary">Submit Review</button>
</form> </form>
</div> </div>
{{if .Comments}}
<h2>Comments</h2>
<div id="comments-list">
{{range .Comments}}
<div class="comment">
<div class="comment-header">
<strong>{{.User}}</strong>
<span>{{.CreatedAt}}</span>
</div>
<div class="comment-body">{{.Body}}</div>
</div>
{{end}}
</div>
{{else}}
<p class="empty" style="margin-top:1rem;">No comments yet.</p>
{{end}}
{{end}} {{end}}
+2 -8
View File
@@ -17,7 +17,7 @@
</div> </div>
{{end}} {{end}}
{{if .HasMore}} {{if .HasMore}}
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}&repo={{.SelectedRepo}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this"> <div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" 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}}
@@ -33,13 +33,7 @@
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option> <option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}} {{end}}
</select> </select>
{{if .Repos}} <select name="state" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']">
<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']">
<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']">
<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>