Compare commits

..

1 Commits

Author SHA1 Message Date
agent-company 2c32e1c6aa 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:10:12 +00:00
10 changed files with 291 additions and 1025 deletions
+135 -260
View File
@@ -308,243 +308,173 @@ func (c *Client) ListOrgsAndRepos(ctx context.Context, token string) (map[string
return result, nil return result, nil
} }
// PageSize is the number of items returned per page for paginated listings. // ListAllIssues fetches all open issues across all repos in the given orgs,
const PageSize = 20 // using concurrent requests with a semaphore.
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string) ([]Issue, error) {
// PaginatedIssues holds a page of issues along with pagination metadata. cacheKey := fmt.Sprintf("issues-%s", strings.Join(orgs, ","))
type PaginatedIssues struct { if cached, ok := c.getFromCache(cacheKey); ok {
Issues []Issue return cached.([]Issue), nil
HasMore bool
}
// 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.
func (c *Client) ListAllIssues(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedIssues, error) {
if state == "" {
state = "open"
}
if page < 1 {
page = 1
} }
cacheKey := fmt.Sprintf("issues-%s-%s", state, strings.Join(orgs, ",")) // 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 allIssues []Issue
if cached, ok := c.getFromCache(cacheKey); ok { var mu sync.Mutex
allIssues = cached.([]Issue) sem := make(chan struct{}, c.maxConcurrent)
} else { var wg sync.WaitGroup
// First, collect all repos for the given orgs. var firstErr error
var allRepos []Repo
for _, org := range orgs { for _, repo := range allRepos {
repos, err := c.ListOrgRepos(ctx, token, org) 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 { if err != nil {
return PaginatedIssues{}, fmt.Errorf("listing repos for %s: %w", org, err)
}
allRepos = append(allRepos, repos...)
}
// 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)
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() mu.Lock()
allIssues = append(allIssues, issues...) if firstErr == nil {
firstErr = fmt.Errorf("fetching issues for %s: %w", r.FullName, err)
}
mu.Unlock() mu.Unlock()
}(repo) return
} }
defer resp.Body.Close()
wg.Wait() 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
}
if firstErr != nil { // Tag each issue with repo info.
return PaginatedIssues{}, firstErr for i := range issues {
} issues[i].RepoOwner = r.Owner.Login
issues[i].RepoName = r.Name
}
// Sort by updated time, newest first. mu.Lock()
sort.Slice(allIssues, func(i, j int) bool { allIssues = append(allIssues, issues...)
return allIssues[i].UpdatedAt.After(allIssues[j].UpdatedAt) mu.Unlock()
}) }(repo)
c.setCache(cacheKey, allIssues)
} }
// Paginate. wg.Wait()
start := (page - 1) * PageSize
if start >= len(allIssues) { if firstErr != nil {
return PaginatedIssues{}, nil return nil, firstErr
}
end := start + PageSize
hasMore := end < len(allIssues)
if end > len(allIssues) {
end = len(allIssues)
} }
return PaginatedIssues{Issues: allIssues[start:end], HasMore: hasMore}, nil // 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
} }
// ListAllPullRequests fetches PRs across all repos in the given orgs. // ListAllPullRequests fetches all open PRs across all repos in the given orgs.
// Results are paginated. func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string) ([]PullRequest, error) {
func (c *Client) ListAllPullRequests(ctx context.Context, token string, orgs []string, state string, page int) (PaginatedPulls, error) { cacheKey := fmt.Sprintf("pulls-%s", strings.Join(orgs, ","))
if state == "" {
state = "open"
}
if page < 1 {
page = 1
}
cacheKey := fmt.Sprintf("pulls-%s-%s", state, strings.Join(orgs, ","))
var allPRs []PullRequest
if cached, ok := c.getFromCache(cacheKey); ok { if cached, ok := c.getFromCache(cacheKey); ok {
allPRs = cached.([]PullRequest) return cached.([]PullRequest), nil
} else { }
var allRepos []Repo
for _, org := range orgs { var allRepos []Repo
repos, err := c.ListOrgRepos(ctx, token, org) 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)
if err != nil { if err != nil {
return PaginatedPulls{}, fmt.Errorf("listing repos for %s: %w", org, err)
}
allRepos = append(allRepos, 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/pulls?state=%s&limit=50", r.FullName, state)
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() mu.Lock()
allPRs = append(allPRs, prs...) if firstErr == nil {
firstErr = fmt.Errorf("fetching PRs for %s: %w", r.FullName, err)
}
mu.Unlock() mu.Unlock()
}(repo) return
} }
defer resp.Body.Close()
wg.Wait() 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
}
if firstErr != nil { for i := range prs {
return PaginatedPulls{}, firstErr prs[i].RepoOwner = r.Owner.Login
} prs[i].RepoName = r.Name
}
sort.Slice(allPRs, func(i, j int) bool { mu.Lock()
return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt) allPRs = append(allPRs, prs...)
}) mu.Unlock()
}(repo)
c.setCache(cacheKey, allPRs)
} }
// Paginate. wg.Wait()
start := (page - 1) * PageSize
if start >= len(allPRs) { if firstErr != nil {
return PaginatedPulls{}, nil return nil, firstErr
}
end := start + PageSize
hasMore := end < len(allPRs)
if end > len(allPRs) {
end = len(allPRs)
} }
return PaginatedPulls{Pulls: allPRs[start:end], HasMore: hasMore}, nil sort.Slice(allPRs, func(i, j int) bool {
return allPRs[i].UpdatedAt.After(allPRs[j].UpdatedAt)
})
c.setCache(cacheKey, allPRs)
return allPRs, nil
} }
// GetTriageQueue returns unassigned issues and PRs needing review, sorted by priority. // GetTriageQueue returns unassigned issues and PRs needing review, sorted by priority.
func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) { func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string) ([]TriageItem, error) {
// Collect all open issues across all pages. issues, err := c.ListAllIssues(ctx, token, orgs)
var issues []Issue if err != nil {
for page := 1; ; page++ { return nil, fmt.Errorf("fetching issues for triage: %w", err)
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
}
} }
// Collect all open PRs across all pages. prs, err := c.ListAllPullRequests(ctx, token, orgs)
var prs []PullRequest if err != nil {
for page := 1; ; page++ { return nil, fmt.Errorf("fetching PRs for triage: %w", err)
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 var queue []TriageItem
@@ -776,61 +706,6 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in
return nil 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. // RenderMarkdown renders raw markdown text to HTML using the Gitea API.
// Falls back to the raw text if the API call fails. // Falls back to the raw text if the API call fails.
func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string, error) { func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string, error) {
-161
View File
@@ -201,167 +201,6 @@ 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. // sortTriageQueue is a test helper applying the same sort as GetTriageQueue.
func sortTriageQueue(queue []TriageItem) { func sortTriageQueue(queue []TriageItem) {
for i := 0; i < len(queue); i++ { for i := 0; i < len(queue); i++ {
+111 -380
View File
@@ -38,14 +38,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Issues. // Issues.
mux.HandleFunc("GET /issues", h.ListIssues) mux.HandleFunc("GET /issues", h.ListIssues)
mux.HandleFunc("GET /issues/new", h.NewIssue)
mux.HandleFunc("GET /issues/new/labels", h.NewIssueLabels)
mux.HandleFunc("POST /issues", h.CreateIssue) mux.HandleFunc("POST /issues", h.CreateIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels) mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/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. // Issue detail.
mux.HandleFunc("GET /issues/{owner}/{repo}/{index}", h.IssueDetail) mux.HandleFunc("GET /issues/{owner}/{repo}/{index}", h.IssueDetail)
@@ -118,10 +112,6 @@ var basePage = template.Must(template.New("base").Parse(`<!DOCTYPE html>
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
</head> </head>
<body> <body>
<header class="top-bar">
<span class="top-bar-title">Gitea Mobile</span>
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">&#8635;</button>
</header>
<div class="content" id="main-content"> <div class="content" id="main-content">
{{.Content}} {{.Content}}
</div> </div>
@@ -190,211 +180,158 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
orgs := h.getUserOrgs(r) orgs := h.getUserOrgs(r)
type dashboardData struct {
Items []giteaclient.TriageItem
Error string
}
var data dashboardData
if len(orgs) == 0 { if len(orgs) == 0 {
data.Error = "No organizations found. Check your token permissions." renderPage(w, r, "Dashboard", "dashboard",
} else { `<h1>Dashboard</h1><p class="empty">No organizations found. Check your token permissions.</p>`)
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs) return
if err != nil {
slog.Error("failed to get triage queue", "error", err)
data.Error = "Error loading triage queue."
} else {
data.Items = queue
}
} }
tmpl, err := template.ParseFiles("internal/templates/dashboard.html") queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
if err != nil { if err != nil {
slog.Error("failed to parse dashboard template", "error", err) slog.Error("failed to get triage queue", "error", err)
http.Error(w, "template error", http.StatusInternalServerError) renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">Error loading triage queue.</p>`)
return return
} }
var buf strings.Builder if len(queue) == 0 {
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { renderPage(w, r, "Dashboard", "dashboard",
slog.Error("failed to execute dashboard template", "error", err) `<h1>Dashboard</h1><p class="empty">No items need attention. Nice work!</p>`)
http.Error(w, "template error", http.StatusInternalServerError)
return return
} }
renderPage(w, r, "Dashboard", "dashboard", buf.String()) content := `<h1>Dashboard</h1>`
for _, item := range queue {
typeBadge := `<span class="type-badge type-issue">issue</span>`
if item.Type == "pull" {
typeBadge = `<span class="type-badge type-pull">PR</span>`
}
labels := ""
for _, l := range item.Labels {
color := "#8b949e"
switch l {
case "P1":
color = "#f85149"
case "P2":
color = "#d29922"
case "P3":
color = "#58a6ff"
}
labels += fmt.Sprintf(`<span class="label" style="color:%s;border:1px solid %s">%s</span>`, color, color, template.HTMLEscapeString(l))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s %s</div>
<div class="card-meta">%s/%s #%d %s</div>
</div>`, typeBadge, template.HTMLEscapeString(item.Title),
template.HTMLEscapeString(item.RepoOwner),
template.HTMLEscapeString(item.RepoName),
item.Number, labels)
}
renderPage(w, r, "Dashboard", "dashboard", content)
} }
// ListIssues handles GET /issues. // ListIssues handles GET /issues.
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
orgNames := h.getUserOrgs(r) orgs := h.getUserOrgs(r)
type issuesData struct { if len(orgs) == 0 {
Issues []giteaclient.Issue renderPage(w, r, "Issues", "issues",
Orgs []string `<h1>Issues</h1><p class="empty">No organizations found.</p>`)
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
}
data := issuesData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
SelectedState: selectedState,
}
if len(orgNames) == 0 {
data.Error = "No organizations found."
} else {
// Filter to selected org if specified.
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
result, err := h.Client.ListAllIssues(r.Context(), token, queryOrgs, selectedState, page)
if err != nil {
slog.Error("failed to list issues", "error", err)
data.Error = "Error loading issues."
} else {
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 return
} }
tmpl, err := template.ParseFiles("internal/templates/issues.html") issues, err := h.Client.ListAllIssues(r.Context(), token, orgs)
if err != nil { if err != nil {
slog.Error("failed to parse issues template", "error", err) slog.Error("failed to list issues", "error", err)
http.Error(w, "template error", http.StatusInternalServerError) renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">Error loading issues.</p>`)
return return
} }
var buf strings.Builder if len(issues) == 0 {
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { renderPage(w, r, "Issues", "issues",
slog.Error("failed to execute issues template", "error", err) `<h1>Issues</h1><p class="empty">No open issues found.</p>`)
http.Error(w, "template error", http.StatusInternalServerError)
return return
} }
renderPage(w, r, "Issues", "issues", buf.String()) content := `<h1>Issues</h1>`
for _, issue := range issues {
labels := ""
for _, l := range issue.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
assignee := ""
if issue.Assignee != nil {
assignee = fmt.Sprintf(` &middot; %s`, template.HTMLEscapeString(issue.Assignee.Login))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s</div>
<div class="card-meta">%s/%s #%d %s%s</div>
</div>`, template.HTMLEscapeString(issue.Title),
template.HTMLEscapeString(issue.RepoOwner),
template.HTMLEscapeString(issue.RepoName),
issue.Number, labels, assignee)
}
renderPage(w, r, "Issues", "issues", content)
} }
// ListPulls handles GET /pulls. // ListPulls handles GET /pulls.
func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
orgNames := h.getUserOrgs(r) orgs := h.getUserOrgs(r)
type pullsData struct { if len(orgs) == 0 {
Pulls []giteaclient.PullRequest renderPage(w, r, "Pull Requests", "pulls",
Orgs []string `<h1>Pull Requests</h1><p class="empty">No organizations found.</p>`)
SelectedOrg string
HasMore bool
NextPage int
Error string
}
selectedOrg := r.URL.Query().Get("org")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
data := pullsData{
Orgs: orgNames,
SelectedOrg: selectedOrg,
}
if len(orgNames) == 0 {
data.Error = "No organizations found."
} else {
queryOrgs := orgNames
if selectedOrg != "" {
queryOrgs = []string{selectedOrg}
}
result, err := h.Client.ListAllPullRequests(r.Context(), token, queryOrgs, "open", page)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
data.Error = "Error loading pull requests."
} else {
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 return
} }
tmpl, err := template.ParseFiles("internal/templates/pulls.html") prs, err := h.Client.ListAllPullRequests(r.Context(), token, orgs)
if err != nil { if err != nil {
slog.Error("failed to parse pulls template", "error", err) slog.Error("failed to list pull requests", "error", err)
http.Error(w, "template error", http.StatusInternalServerError) renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">Error loading pull requests.</p>`)
return return
} }
var buf strings.Builder if len(prs) == 0 {
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { renderPage(w, r, "Pull Requests", "pulls",
slog.Error("failed to execute pulls template", "error", err) `<h1>Pull Requests</h1><p class="empty">No open pull requests found.</p>`)
http.Error(w, "template error", http.StatusInternalServerError)
return return
} }
renderPage(w, r, "Pull Requests", "pulls", buf.String()) content := `<h1>Pull Requests</h1>`
for _, pr := range prs {
labels := ""
for _, l := range pr.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
stats := fmt.Sprintf(`<span style="color:#3fb950">+%d</span> <span style="color:#f85149">-%d</span>`, pr.Additions, pr.Deletions)
mergeStatus := ""
if pr.Mergeable {
mergeStatus = `<span style="color:#3fb950;font-size:0.7rem;">mergeable</span>`
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title"><span class="type-badge type-pull">PR</span> %s</div>
<div class="card-meta">%s/%s #%d %s %s %s</div>
</div>`, template.HTMLEscapeString(pr.Title),
template.HTMLEscapeString(pr.RepoOwner),
template.HTMLEscapeString(pr.RepoName),
pr.Number, labels, stats, mergeStatus)
}
renderPage(w, r, "Pull Requests", "pulls", content)
} }
// IssueDetail handles GET /issues/{owner}/{repo}/{index}. // IssueDetail handles GET /issues/{owner}/{repo}/{index}.
@@ -533,73 +470,6 @@ func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
renderPage(w, r, fmt.Sprintf("PR #%d", index), "pulls", buf.String()) 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. // CreateIssue handles POST /issues.
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
@@ -613,35 +483,14 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
title := r.FormValue("title") title := r.FormValue("title")
body := r.FormValue("body") body := r.FormValue("body")
// Parse label IDs from form checkboxes.
var labelIDs []int64
for _, idStr := range r.Form["label_ids"] {
id, err := strconv.ParseInt(idStr, 10, 64)
if err == nil {
labelIDs = append(labelIDs, id)
}
}
if owner == "" || repo == "" || title == "" { if owner == "" || repo == "" || title == "" {
if isHTMX(r) {
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) http.Error(w, "owner, repo, and title are required", http.StatusBadRequest)
return return
} }
issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, labelIDs) issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, nil)
if err != nil { if err != nil {
slog.Error("failed to create issue", "error", err) slog.Error("failed to create issue", "error", err)
if isHTMX(r) {
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) http.Error(w, "failed to create issue", http.StatusInternalServerError)
return return
} }
@@ -702,124 +551,6 @@ func (h *Handler) ApplyLabels(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther) http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
} }
// 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)
}
// 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. // SubmitReview handles POST /pulls/{owner}/{repo}/{index}/review.
func (h *Handler) SubmitReview(w http.ResponseWriter, r *http.Request) { func (h *Handler) SubmitReview(w http.ResponseWriter, r *http.Request) {
token := getToken(r) token := getToken(r)
-48
View File
@@ -135,54 +135,6 @@ func TestSubmitReview_MissingEventType(t *testing.T) {
} }
} }
func TestSetIssueState_InvalidState(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/1/state", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestSetIssueState_InvalidIndex(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/state", h.SetIssueState)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/abc/state", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestAddComment_EmptyBody(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comments", h.AddComment)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/1/comments", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func contains(s, substr string) bool { func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr) return len(s) >= len(substr) && searchString(s, substr)
} }
+7 -58
View File
@@ -1,9 +1,7 @@
{{define "content"}} {{define "content"}}
<h1>Create Issue</h1> <h1>Create Issue</h1>
<div id="form-error" class="empty" style="display:none;"></div> <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> <select id="repo-select" name="owner_repo" required>
@@ -20,11 +18,6 @@
<input type="hidden" name="repo" id="repo-input"> <input type="hidden" name="repo" id="repo-input">
</div> </div>
<div class="form-group" id="label-section" style="display:none;">
<label>Labels</label>
<div id="label-list"></div>
</div>
<div class="form-group"> <div class="form-group">
<label for="title">Title</label> <label for="title">Title</label>
<input type="text" id="title" name="title" placeholder="Issue title" required> <input type="text" id="title" name="title" placeholder="Issue title" required>
@@ -39,55 +32,11 @@
</form> </form>
<script> <script>
(function() { // Split owner/repo from select into hidden fields.
var repoSelect = document.getElementById('repo-select'); document.getElementById('repo-select').addEventListener('change', function() {
var ownerInput = document.getElementById('owner-input'); var parts = this.value.split('/');
var repoInput = document.getElementById('repo-input'); document.getElementById('owner-input').value = parts[0] || '';
var formError = document.getElementById('form-error'); document.getElementById('repo-input').value = parts[1] || '';
});
function splitOwnerRepo() {
var val = repoSelect.value;
if (val) {
var parts = val.split('/');
ownerInput.value = parts[0] || '';
repoInput.value = parts[1] || '';
} else {
ownerInput.value = '';
repoInput.value = '';
}
}
repoSelect.addEventListener('change', function() {
splitOwnerRepo();
var labelSection = document.getElementById('label-section');
var labelList = document.getElementById('label-list');
if (ownerInput.value && repoInput.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;
}
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> </script>
{{end}} {{end}}
+1 -21
View File
@@ -3,15 +3,7 @@
<div class="card"> <div class="card">
<div class="card-meta"> <div class="card-meta">
<span id="state-section"> <span class="state-open">{{.Issue.State}}</span>
{{if eq .Issue.State "closed"}}
<span class="state-closed" id="issue-state">{{.Issue.State}}</span>
<button class="btn btn-secondary" hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/state" hx-vals='{"state":"open"}' hx-target="#state-section" hx-swap="innerHTML">Reopen Issue</button>
{{else}}
<span class="state-open" id="issue-state">{{.Issue.State}}</span>
<button class="btn btn-danger" hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/state" hx-vals='{"state":"closed"}' hx-target="#state-section" hx-swap="innerHTML">Close Issue</button>
{{end}}
</span>
<span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span> <span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span>
{{range .Issue.Labels}} {{range .Issue.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span> <span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
@@ -26,7 +18,6 @@
{{if .Comments}} {{if .Comments}}
<h2>Comments</h2> <h2>Comments</h2>
<div id="comments-list">
{{range .Comments}} {{range .Comments}}
<div class="comment"> <div class="comment">
<div class="comment-header"> <div class="comment-header">
@@ -36,19 +27,8 @@
<div class="comment-body">{{.Body}}</div> <div class="comment-body">{{.Body}}</div>
</div> </div>
{{end}} {{end}}
</div>
{{else}}
<div id="comments-list"></div>
{{end}} {{end}}
<div class="card" style="margin-top:1rem;">
<h2>Add Comment</h2>
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/comments" hx-target="#comments-list" hx-swap="beforeend" hx-on::after-request="if(event.detail.successful) this.reset()">
<textarea name="body" rows="4" placeholder="Write a comment..." required style="width:100%;margin-bottom:0.5rem;"></textarea>
<button type="submit" class="btn btn-primary" style="width:auto;padding:0.5rem 1rem;">Comment</button>
</form>
</div>
<div class="card" style="margin-top:1rem;"> <div class="card" style="margin-top:1rem;">
<h2>Actions</h2> <h2>Actions</h2>
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;"> <form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;">
+19 -23
View File
@@ -1,25 +1,3 @@
{{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>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
{{if .Assignee}}
<span>{{.Assignee.Login}}</span>
{{end}}
</div>
</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="spinner htmx-indicator"></div>
</div>
{{end}}
{{end}}
{{define "content"}} {{define "content"}}
<h1>Issues</h1> <h1>Issues</h1>
@@ -42,7 +20,25 @@
<p class="empty">No issues found.</p> <p class="empty">No issues found.</p>
{{else}} {{else}}
<div id="issue-list"> <div id="issue-list">
{{template "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>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
{{if .Assignee}}
<span>{{.Assignee.Login}}</span>
{{end}}
</div>
</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="spinner htmx-indicator"></div>
</div>
{{end}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
-4
View File
@@ -13,10 +13,6 @@
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
</head> </head>
<body> <body>
<header class="top-bar">
<span class="top-bar-title">Gitea Mobile</span>
<button class="refresh-btn" hx-get="" hx-target="#main-content" hx-swap="innerHTML" aria-label="Refresh">&#8635;</button>
</header>
<div class="content" id="main-content"> <div class="content" id="main-content">
{{template "content" .}} {{template "content" .}}
</div> </div>
+17 -26
View File
@@ -1,28 +1,3 @@
{{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">
<span class="type-badge type-pull">PR</span>
{{.Title}}
</div>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
<span class="diff-add">+{{.Additions}}</span>
<span class="diff-del">-{{.Deletions}}</span>
{{if .Mergeable}}<span style="color:var(--accent-green);font-size:0.7rem;">mergeable</span>{{end}}
</div>
</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="spinner htmx-indicator"></div>
</div>
{{end}}
{{end}}
{{define "content"}} {{define "content"}}
<h1>Pull Requests</h1> <h1>Pull Requests</h1>
@@ -41,7 +16,23 @@
<p class="empty">No open pull requests found.</p> <p class="empty">No open pull requests found.</p>
{{else}} {{else}}
<div id="pull-list"> <div id="pull-list">
{{template "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">
<span class="type-badge type-pull">PR</span>
{{.Title}}
</div>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
<span class="diff-add">+{{.Additions}}</span>
<span class="diff-del">-{{.Deletions}}</span>
{{if .Mergeable}}<span style="color:var(--accent-green);font-size:0.7rem;">mergeable</span>{{end}}
</div>
</div>
{{end}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
+1 -44
View File
@@ -47,53 +47,10 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Top bar */
.top-bar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-lg);
padding-top: max(var(--spacing-sm), env(safe-area-inset-top));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.top-bar-title {
font-size: var(--font-base);
font-weight: 600;
color: var(--text-primary);
}
.refresh-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.4rem;
padding: var(--spacing-xs) var(--spacing-sm);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
line-height: 1;
border-radius: var(--radius-sm);
transition: color 0.15s ease, background 0.15s ease;
}
.refresh-btn:active {
color: var(--accent-blue);
background: var(--bg-tertiary);
}
.refresh-btn.htmx-request {
animation: spin 0.6s linear infinite;
pointer-events: none;
}
/* Content area */ /* Content area */
.content { .content {
padding: var(--spacing-lg); padding: var(--spacing-lg);
padding-top: var(--spacing-lg); padding-top: max(var(--spacing-lg), env(safe-area-inset-top));
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
} }