Compare commits

...

8 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
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 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
6 changed files with 178 additions and 16 deletions
+43
View File
@@ -753,6 +753,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{}{
+78 -3
View File
@@ -42,6 +42,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
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)
@@ -189,18 +190,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
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."
@@ -324,12 +337,17 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
Pulls []giteaclient.PullRequest
Orgs []string
SelectedOrg string
SelectedState string
HasMore bool
NextPage int
Error string
}
selectedOrg := r.URL.Query().Get("org")
selectedState := r.URL.Query().Get("state")
if selectedState == "" {
selectedState = "open"
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
@@ -338,6 +356,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
data := pullsData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
}
if len(orgNames) == 0 {
@@ -348,7 +367,7 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
queryOrgs = []string{selectedOrg}
}
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, "open", page)
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, selectedState, page)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
@@ -430,6 +449,12 @@ 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 != "" {
@@ -454,6 +479,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
RenderedBody template.HTML
Comments []giteaclient.Comment
AvailableLabels []giteaclient.Label
Collaborators []string
}
data := templateData{
@@ -461,6 +487,7 @@ func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
RenderedBody: renderedBody,
Comments: comments,
AvailableLabels: labels,
Collaborators: collaborators,
}
var buf strings.Builder
@@ -505,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.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil {
@@ -516,11 +550,13 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
type templateData struct {
Pull *giteaclient.PullRequest
RenderedBody template.HTML
Comments []giteaclient.Comment
}
data := templateData{
Pull: pr,
RenderedBody: renderedBody,
Comments: comments,
}
var buf strings.Builder
@@ -702,6 +738,45 @@ func (h *Handler) ApplyLabels(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// 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)
+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}}
+12
View File
@@ -51,6 +51,18 @@
<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">
+17
View File
@@ -46,4 +46,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}}
+7 -3
View File
@@ -17,7 +17,7 @@
</div>
{{end}}
{{if .HasMore}}
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="scroll-sentinel" hx-get="/pulls?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
@@ -27,18 +27,22 @@
<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">
<select name="org" hx-get="/pulls" 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="/pulls" 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 .Pulls}}
<p class="empty">No open pull requests found.</p>
<p class="empty">No {{.SelectedState}} pull requests found.</p>
{{else}}
<div id="pull-list">
{{template "cards" .}}