Compare commits

..

14 Commits

Author SHA1 Message Date
agent-company 67973b27aa chore: improve light mode CSS and document file size rationale
- Add accent color overrides for light mode (better contrast on white bg)
- Add light-mode-specific styles for messages, buttons, type badges
- Document why CSS is ~12KB vs the original ~5KB target (all rules active)
- safe-area-inset and tablet breakpoint already verified present

Closes leeworks-agents/gitea-mobile#119

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:05:36 +00:00
AI-Manager 8c390e7505 Merge pull request 'test: add 43 integration tests for all HTTP handlers' (#146) from feature/integration-tests-batch1 into master
Build and Push / test (push) Failing after 1m9s
Build and Push / build (push) Has been skipped
2026-03-28 19:03:43 +00:00
AI-Manager ffacea132c Merge pull request 'test: add unit tests for GetTriageQueue aggregation' (#147) from feature/unit-tests-triage-queue-117 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 19:03:30 +00:00
AI-Manager f44390a75a Merge pull request 'feat: add rate-limit retry/backoff handling in Gitea API client' (#145) from feature/rate-limit-retry-132 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 19:03:24 +00:00
AI-Manager a7b777cf7e Merge pull request 'feat: add HTTP 404 and 500 error pages with mobile-friendly styling' (#144) from feature/error-handlers-131 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 19:03:12 +00:00
agent-company f0addf8fad test: add unit tests for GetTriageQueue aggregation
Add 4 new integration-style unit tests for GetTriageQueue using mock
HTTP servers: full integration test verifying issue filtering (assigned
vs unassigned), PR inclusion, and priority sorting; empty orgs test;
all-assigned test (expect empty queue); and label extraction test
verifying multi-label items are correctly populated.

Closes leeworks-agents/gitea-mobile#117

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:14:37 +00:00
agent-company 2ea20da5ef test: add 43 integration tests for all HTTP handlers
Add comprehensive integration test suite using httptest with a mock
Gitea API server. Tests cover GET and POST handlers for dashboard,
issues, pulls, issue/PR detail, create issue, state changes, comments,
labels, assignees, reviews, and settings. Both regular and HTMX
request paths are tested. Includes TestMain to set working directory
to project root for template loading.

Covers issues: #140 #139 #138 #137 #136 #135 #134 #133 #124 #118
#113 #111 #110

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:12:53 +00:00
agent-company e6ce6bc6c6 feat: add rate-limit retry with exponential backoff in Gitea API client
Add automatic retry logic to doRequest for HTTP 429 responses. Uses
Retry-After header when present, otherwise exponential backoff
(1s, 2s, 4s). Respects context cancellation during waits. Defaults
to 3 max retries with 1s base delay. Includes 7 new tests covering
retry success, exhaustion, Retry-After header, context cancellation,
non-429 errors, and backoff calculation.

Closes leeworks-agents/gitea-mobile#132

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:08:48 +00:00
agent-company 25bc305fc9 feat: add mobile-friendly HTTP 404 and 500 error pages
Add ErrorNotFound and ErrorInternal handler methods that render styled
error pages using the error.html template, with proper status codes,
responsive layout, SVG icons, and HTMX fragment support. Replace the
plain-text http.NotFound call in Dashboard with the new styled handler.

Closes leeworks-agents/gitea-mobile#131

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:06:10 +00:00
AI-Manager 77c8e92e38 Merge pull request 'test: unit tests for SubmitReview and ApplyLabel client methods' (#130) from feature/unit-tests-submit-review-apply-label-127 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 15:03:40 +00:00
AI-Manager 2566e14bef Merge pull request 'chore: extract settings template to HTML file' (#129) from feature/extract-settings-template-126 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 15:03:35 +00:00
AI-Manager b0747c0239 Merge pull request 'feat: wire GITEA_TOKEN env var as auth fallback' (#128) from feature/gitea-token-fallback-125 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 15:03:23 +00:00
agent-company becb925456 test: add unit tests for SubmitReview and ApplyLabel client methods
Add four test functions using httptest.NewServer:
- TestApplyLabel: verifies POST request path, auth header, label IDs
  in body, and cache invalidation after success
- TestApplyLabel_Error: verifies 404 error propagation
- TestSubmitReview: verifies POST path, event/body fields, and cache
  invalidation after success
- TestSubmitReview_Error: verifies 422 error propagation

Closes leeworks-agents/gitea-mobile#127

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:08:23 +00:00
agent-company eeea1b6475 chore: extract inline settings template to internal/templates/settings.html
Move the large inline HTML template from settings.go into a separate
file at internal/templates/settings.html, matching the project convention
used by all other handlers. The template is now loaded at render time
via template.ParseFiles, consistent with dashboard, issues, etc.

Closes leeworks-agents/gitea-mobile#126

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:06:25 +00:00
9 changed files with 2019 additions and 110 deletions
+91 -20
View File
@@ -8,8 +8,11 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -27,6 +30,11 @@ type Client struct {
maxConcurrent int
// cacheTTL controls how long cache entries remain valid.
cacheTTL time.Duration
// maxRetries is the maximum number of retries for rate-limited requests.
maxRetries int
// baseRetryDelay is the initial backoff delay before the first retry.
baseRetryDelay time.Duration
}
type cacheEntry struct {
@@ -129,39 +137,102 @@ func NewClient(baseURL string) *Client {
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
cache: make(map[string]*cacheEntry),
maxConcurrent: 5,
cacheTTL: 30 * time.Second,
cache: make(map[string]*cacheEntry),
maxConcurrent: 5,
cacheTTL: 30 * time.Second,
maxRetries: 3,
baseRetryDelay: 1 * time.Second,
}
}
// doRequest performs an authenticated HTTP request to the Gitea API.
// It automatically retries on HTTP 429 (rate limit) responses with
// exponential backoff, respecting the Retry-After header when present.
func (c *Client) doRequest(ctx context.Context, token, method, path string, body io.Reader) (*http.Response, error) {
url := c.baseURL + "/api/v1" + path
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Accept", "application/json")
// Read the body once so we can replay it on retries.
var bodyBytes []byte
if body != nil {
req.Header.Set("Content-Type", "application/json")
var err error
bodyBytes, err = io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("reading request body: %w", err)
}
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
// Recreate the body reader for each attempt.
var reqBody io.Reader
if bodyBytes != nil {
reqBody = strings.NewReader(string(bodyBytes))
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Accept", "application/json")
if bodyBytes != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
// Not rate-limited: handle normally.
if resp.StatusCode != http.StatusTooManyRequests {
if resp.StatusCode >= 400 {
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
}
return resp, nil
}
// Rate-limited (429): close body and compute retry delay.
resp.Body.Close()
if attempt == c.maxRetries {
lastErr = fmt.Errorf("API rate limit exceeded after %d retries (429)", c.maxRetries)
break
}
delay := c.retryDelay(resp, attempt)
slog.Warn("rate limited by Gitea API, retrying",
"attempt", attempt+1,
"max_retries", c.maxRetries,
"delay", delay,
"path", path,
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
// Continue to next attempt.
}
}
if resp.StatusCode >= 400 {
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
}
return nil, lastErr
}
return resp, nil
// retryDelay computes the delay before the next retry attempt. It uses the
// Retry-After header value (in seconds) if present, otherwise falls back to
// exponential backoff: baseRetryDelay * 2^attempt.
func (c *Client) retryDelay(resp *http.Response, attempt int) time.Duration {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if seconds, err := strconv.Atoi(ra); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
}
// Exponential backoff: 1s, 2s, 4s, ...
return c.baseRetryDelay * time.Duration(math.Pow(2, float64(attempt)))
}
// getFromCache returns cached data if still valid.
+504
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -376,6 +377,217 @@ func sortTriageQueue(queue []TriageItem) {
}
}
// --- Issue #117: Tests for GetTriageQueue aggregation ---
func TestGetTriageQueue_Integration(t *testing.T) {
// Mock server that returns issues (some assigned, some not) and PRs.
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues") && r.Method == "GET":
// Return mix of assigned and unassigned issues.
issues := []map[string]interface{}{
{
"id": 1, "number": 1, "title": "Unassigned bug",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{{"id": 1, "name": "P1", "color": "ff0000"}},
"html_url": "http://example.com/org1/repo1/issues/1",
},
{
"id": 2, "number": 2, "title": "Assigned issue",
"state": "open",
"assignee": map[string]string{"login": "dev1", "avatar_url": ""},
"assignees": []map[string]string{{"login": "dev1", "avatar_url": ""}},
"labels": []interface{}{},
"html_url": "http://example.com/org1/repo1/issues/2",
},
{
"id": 3, "number": 3, "title": "Unassigned low priority",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{{"id": 2, "name": "P3", "color": "00ff00"}},
"html_url": "http://example.com/org1/repo1/issues/3",
},
}
json.NewEncoder(w).Encode(issues)
case strings.HasSuffix(r.URL.Path, "/pulls") && r.Method == "GET":
prs := []map[string]interface{}{
{
"id": 10, "number": 10, "title": "Open PR needs review",
"state": "open", "body": "please review",
"labels": []map[string]interface{}{{"id": 3, "name": "P2", "color": "ffff00"}},
"html_url": "http://example.com/org1/repo1/pulls/10",
"head": map[string]string{"label": "feature", "ref": "feature"},
"base": map[string]string{"label": "master", "ref": "master"},
},
}
json.NewEncoder(w).Encode(prs)
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
// Should include: 2 unassigned issues + 1 PR = 3 items.
// Assigned issue (#2) should be excluded.
if len(queue) != 3 {
t.Fatalf("expected 3 triage items, got %d", len(queue))
}
// Verify sorting: P1 > P2 > P3.
if queue[0].Title != "Unassigned bug" {
t.Errorf("queue[0] should be P1 'Unassigned bug', got %q", queue[0].Title)
}
if queue[1].Title != "Open PR needs review" {
t.Errorf("queue[1] should be P2 'Open PR needs review', got %q", queue[1].Title)
}
if queue[2].Title != "Unassigned low priority" {
t.Errorf("queue[2] should be P3 'Unassigned low priority', got %q", queue[2].Title)
}
// Verify types.
if queue[0].Type != "issue" {
t.Errorf("queue[0].Type = %q, want 'issue'", queue[0].Type)
}
if queue[1].Type != "pull" {
t.Errorf("queue[1].Type = %q, want 'pull'", queue[1].Type)
}
}
func TestGetTriageQueue_EmptyOrgs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{})
if err != nil {
t.Fatalf("GetTriageQueue with empty orgs: %v", err)
}
if len(queue) != 0 {
t.Errorf("expected empty queue for empty orgs, got %d items", len(queue))
}
}
func TestGetTriageQueue_AllAssigned(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues"):
// All issues are assigned.
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": 1, "number": 1, "title": "Assigned issue",
"state": "open",
"assignee": map[string]string{"login": "dev1"},
"assignees": []map[string]string{{"login": "dev1"}},
"labels": []interface{}{},
"html_url": "http://example.com/org1/repo1/issues/1",
},
})
case strings.HasSuffix(r.URL.Path, "/pulls"):
json.NewEncoder(w).Encode([]interface{}{}) // No PRs.
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
// Only PRs should appear (none here), all issues are assigned.
if len(queue) != 0 {
t.Errorf("expected 0 items (all assigned), got %d", len(queue))
}
}
func TestGetTriageQueue_LabelExtraction(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues"):
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": 1, "number": 1, "title": "Multi-label issue",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{
{"id": 1, "name": "bug", "color": "d73a4a"},
{"id": 2, "name": "P1", "color": "ff0000"},
{"id": 3, "name": "help wanted", "color": "0e8a16"},
},
"html_url": "http://example.com/org1/repo1/issues/1",
},
})
case strings.HasSuffix(r.URL.Path, "/pulls"):
json.NewEncoder(w).Encode([]interface{}{})
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
if len(queue) != 1 {
t.Fatalf("expected 1 item, got %d", len(queue))
}
if len(queue[0].Labels) != 3 {
t.Errorf("expected 3 labels, got %d: %v", len(queue[0].Labels), queue[0].Labels)
}
}
// --- Issue #122: Tests for ListOrgsAndRepos and CreateIssue ---
func TestListOrgsAndRepos(t *testing.T) {
@@ -893,6 +1105,140 @@ func TestListAllPullRequests_StateFilter(t *testing.T) {
}
}
// --- Issue #127: Tests for ApplyLabel and SubmitReview ---
func TestApplyLabel(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/labels" {
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]interface{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
labels, ok := body["labels"].([]interface{})
if !ok {
t.Fatalf("expected labels array, got %T", body["labels"])
}
if len(labels) != 2 {
t.Errorf("expected 2 label IDs, got %d", len(labels))
}
// Verify the label IDs are correct (JSON numbers are float64).
if labels[0].(float64) != 10 || labels[1].(float64) != 20 {
t.Errorf("expected label IDs [10, 20], got %v", labels)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": 10, "name": "bug"},
{"id": 20, "name": "enhancement"},
})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
err := c.ApplyLabel(context.Background(), "test-token", "owner1", "repo1", 42, []int64{10, 20})
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 ApplyLabel")
}
}
func TestApplyLabel_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintln(w, `{"message":"issue not found"}`)
}))
defer server.Close()
c := NewClient(server.URL)
err := c.ApplyLabel(context.Background(), "test-token", "owner1", "repo1", 999, []int64{10})
if err == nil {
t.Fatal("expected error for 404 response, got nil")
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("error should contain status code 404, got: %v", err)
}
}
func TestSubmitReview(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/pulls/7/reviews" {
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["event"] != "APPROVED" {
t.Errorf("expected event=APPROVED, got %q", body["event"])
}
if body["body"] != "Looks good!" {
t.Errorf("expected body='Looks good!', got %q", body["body"])
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"state": "APPROVED",
"body": body["body"],
})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("pulls-org1", "should-be-invalidated")
err := c.SubmitReview(context.Background(), "test-token", "owner1", "repo1", 7, "APPROVED", "Looks good!")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify cache was invalidated.
_, ok := c.getFromCache("pulls-org1")
if ok {
t.Error("expected cache to be invalidated after SubmitReview")
}
}
func TestSubmitReview_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprintln(w, `{"message":"validation failed"}`)
}))
defer server.Close()
c := NewClient(server.URL)
err := c.SubmitReview(context.Background(), "test-token", "owner1", "repo1", 7, "INVALID", "")
if err == nil {
t.Fatal("expected error for 422 response, got nil")
}
if !strings.Contains(err.Error(), "422") {
t.Errorf("error should contain status code 422, got: %v", err)
}
}
func TestListAllPullRequests_Pagination(t *testing.T) {
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
@@ -952,3 +1298,161 @@ func TestListAllPullRequests_Pagination(t *testing.T) {
t.Error("page 2: HasMore should be false")
}
}
func TestDoRequest_RateLimitRetry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts <= 2 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `[{"username":"test-org"}]`)
}))
defer srv.Close()
c := NewClient(srv.URL)
c.maxRetries = 3
c.baseRetryDelay = 1 * time.Millisecond // Fast for tests.
resp, err := c.doRequest(context.Background(), "test-token", "GET", "/user/orgs", nil)
if err != nil {
t.Fatalf("expected success after retries, got: %v", err)
}
resp.Body.Close()
if attempts != 3 {
t.Errorf("expected 3 attempts, got %d", attempts)
}
}
func TestDoRequest_RateLimitExhausted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := NewClient(srv.URL)
c.maxRetries = 2
c.baseRetryDelay = 1 * time.Millisecond
_, err := c.doRequest(context.Background(), "test-token", "GET", "/user/orgs", nil)
if err == nil {
t.Fatal("expected error after exhausting retries")
}
if !strings.Contains(err.Error(), "rate limit exceeded") {
t.Errorf("expected rate limit error, got: %v", err)
}
}
func TestDoRequest_RateLimitWithRetryAfterHeader(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `[]`)
}))
defer srv.Close()
c := NewClient(srv.URL)
c.maxRetries = 3
c.baseRetryDelay = 1 * time.Millisecond
start := time.Now()
resp, err := c.doRequest(context.Background(), "test-token", "GET", "/user/orgs", nil)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
resp.Body.Close()
// Retry-After: 1 means 1 second delay.
if elapsed < 900*time.Millisecond {
t.Errorf("expected at least ~1s delay from Retry-After header, got %v", elapsed)
}
}
func TestDoRequest_RateLimitCancelledContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "60")
w.WriteHeader(http.StatusTooManyRequests)
}))
defer srv.Close()
c := NewClient(srv.URL)
c.maxRetries = 3
c.baseRetryDelay = 1 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := c.doRequest(ctx, "test-token", "GET", "/user/orgs", nil)
if err == nil {
t.Fatal("expected error from cancelled context")
}
}
func TestDoRequest_NonRateLimitErrorNotRetried(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"message":"forbidden"}`)
}))
defer srv.Close()
c := NewClient(srv.URL)
c.maxRetries = 3
c.baseRetryDelay = 1 * time.Millisecond
_, err := c.doRequest(context.Background(), "test-token", "GET", "/user/orgs", nil)
if err == nil {
t.Fatal("expected error for 403")
}
if attempts != 1 {
t.Errorf("expected only 1 attempt for non-429 error, got %d", attempts)
}
}
func TestRetryDelay_WithRetryAfterHeader(t *testing.T) {
c := NewClient("https://example.com")
c.baseRetryDelay = 1 * time.Second
resp := &http.Response{Header: http.Header{}}
resp.Header.Set("Retry-After", "5")
delay := c.retryDelay(resp, 0)
if delay != 5*time.Second {
t.Errorf("expected 5s from Retry-After, got %v", delay)
}
}
func TestRetryDelay_ExponentialBackoff(t *testing.T) {
c := NewClient("https://example.com")
c.baseRetryDelay = 1 * time.Second
resp := &http.Response{Header: http.Header{}}
tests := []struct {
attempt int
want time.Duration
}{
{0, 1 * time.Second},
{1, 2 * time.Second},
{2, 4 * time.Second},
}
for _, tt := range tests {
delay := c.retryDelay(resp, tt.attempt)
if delay != tt.want {
t.Errorf("attempt %d: got %v, want %v", tt.attempt, delay, tt.want)
}
}
}
+48 -1
View File
@@ -181,11 +181,58 @@ func renderPage(w http.ResponseWriter, r *http.Request, title, activeTab string,
}
}
// errorData holds the template data for error pages.
type errorData struct {
Code int
Title string
Message string
}
// ErrorNotFound renders a mobile-friendly 404 error page.
func (h *Handler) ErrorNotFound(w http.ResponseWriter, r *http.Request) {
data := errorData{
Code: http.StatusNotFound,
Title: "Page Not Found",
Message: "The page you are looking for does not exist or has been moved.",
}
h.renderError(w, r, data)
}
// ErrorInternal renders a mobile-friendly 500 error page.
func (h *Handler) ErrorInternal(w http.ResponseWriter, r *http.Request) {
data := errorData{
Code: http.StatusInternalServerError,
Title: "Internal Server Error",
Message: "Something went wrong on our end. Please try again later.",
}
h.renderError(w, r, data)
}
// renderError renders the error template with the given data and status code.
func (h *Handler) renderError(w http.ResponseWriter, r *http.Request, data errorData) {
tmpl, err := template.ParseFiles("internal/templates/error.html")
if err != nil {
slog.Error("failed to parse error template", "error", err)
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
return
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute error template", "error", err)
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
return
}
w.WriteHeader(data.Code)
renderPage(w, r, data.Title, "", buf.String())
}
// Dashboard handles GET / — the triage queue.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
// Only handle exact root path.
if r.URL.Path != "/" {
http.NotFound(w, r)
h.ErrorNotFound(w, r)
return
}
+81
View File
@@ -183,6 +183,87 @@ func TestAddComment_EmptyBody(t *testing.T) {
}
}
func TestErrorNotFound(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
w := httptest.NewRecorder()
h.ErrorNotFound(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
body := w.Body.String()
if body == "" {
t.Error("expected non-empty response body")
}
if !contains(body, "404") {
t.Error("expected body to contain '404'")
}
if !contains(body, "Page Not Found") {
t.Error("expected body to contain 'Page Not Found'")
}
}
func TestErrorInternal(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/error", nil)
w := httptest.NewRecorder()
h.ErrorInternal(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
}
body := w.Body.String()
if body == "" {
t.Error("expected non-empty response body")
}
if !contains(body, "500") {
t.Error("expected body to contain '500'")
}
if !contains(body, "Internal Server Error") {
t.Error("expected body to contain 'Internal Server Error'")
}
}
func TestDashboard_NonRootPath_Returns404(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/unknown/path", nil)
w := httptest.NewRecorder()
h.Dashboard(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
body := w.Body.String()
if !contains(body, "404") {
t.Error("expected body to contain '404' for non-root path")
}
}
func TestErrorNotFound_HTMX(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ErrorNotFound(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
body := w.Body.String()
// HTMX response should not contain DOCTYPE.
if contains(body, "<!DOCTYPE") {
t.Error("HTMX response should not contain DOCTYPE")
}
if !contains(body, "Page Not Found") {
t.Error("expected body to contain 'Page Not Found'")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
File diff suppressed because it is too large Load Diff
+17 -87
View File
@@ -2,6 +2,7 @@ package handlers
import (
"html/template"
"log/slog"
"net/http"
"strings"
@@ -9,89 +10,7 @@ import (
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
)
var settingsTemplate = template.Must(template.New("settings").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Settings Gitea Mobile</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #e6edf3;
padding: 1rem;
padding-top: max(1rem, env(safe-area-inset-top));
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
.card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 1rem; margin-bottom: 1rem;
}
label { display: block; font-size: 0.875rem; color: #8b949e; margin-bottom: 0.5rem; }
input[type="text"], input[type="password"] {
width: 100%; padding: 0.5rem; font-size: 1rem;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
color: #e6edf3; margin-bottom: 1rem;
}
input:focus { outline: none; border-color: #58a6ff; }
button {
width: 100%; padding: 0.75rem; font-size: 1rem; font-weight: 600;
background: #238636; color: #fff; border: none; border-radius: 6px;
cursor: pointer;
}
button:active { background: #2ea043; }
.message {
padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem;
font-size: 0.875rem;
}
.message.success { background: #0d2818; border: 1px solid #238636; color: #3fb950; }
.message.error { background: #2d1117; border: 1px solid #da3633; color: #f85149; }
.message.info { background: #0c1d2e; border: 1px solid #1f6feb; color: #58a6ff; }
.hint { font-size: 0.75rem; color: #8b949e; margin-top: 0.25rem; margin-bottom: 1rem; }
.status { font-size: 0.875rem; color: #8b949e; }
.status .connected { color: #3fb950; }
.logout-btn {
background: #21262d; border: 1px solid #30363d; margin-top: 0.5rem;
}
.logout-btn:active { background: #30363d; }
</style>
</head>
<body>
<h1>Settings</h1>
{{if .Message}}
<div class="message {{.MessageType}}">{{.Message}}</div>
{{end}}
{{if .HasToken}}
<div class="card">
<p class="status">Status: <span class="connected">Connected</span></p>
<p class="hint">A Gitea API token is configured.</p>
<form method="POST" action="/settings">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Remove Token</button>
</form>
</div>
{{end}}
<div class="card">
<form method="POST" action="/settings">
<input type="hidden" name="action" value="save">
<label for="token">Gitea API Token</label>
<input type="password" id="token" name="token" placeholder="Enter your Gitea API token" required>
<p class="hint">Generate a token at your Gitea instance under Settings &rarr; Applications.</p>
<button type="submit">{{if .HasToken}}Update Token{{else}}Save Token{{end}}</button>
</form>
</div>
{{if .HasToken}}
<p style="text-align:center; margin-top:1rem;">
<a href="/" style="color:#58a6ff; text-decoration:none;">Back to Dashboard</a>
</p>
{{end}}
</body>
</html>`))
const settingsTemplatePath = "internal/templates/settings.html"
// SettingsHandler handles GET and POST requests for the settings page.
type SettingsHandler struct {
@@ -126,8 +45,7 @@ func (h *SettingsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
}
data := settingsData{HasToken: hasToken}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
settingsTemplate.Execute(w, data)
h.renderSettings(w, data)
}
func (h *SettingsHandler) handlePost(w http.ResponseWriter, r *http.Request) {
@@ -172,6 +90,18 @@ func (h *SettingsHandler) renderWithMessage(w http.ResponseWriter, r *http.Reque
Message: msg,
MessageType: msgType,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
settingsTemplate.Execute(w, data)
h.renderSettings(w, data)
}
func (h *SettingsHandler) renderSettings(w http.ResponseWriter, data settingsData) {
tmpl, err := template.ParseFiles(settingsTemplatePath)
if err != nil {
slog.Error("failed to parse settings template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
slog.Error("failed to execute settings template", "error", err)
}
}
+23
View File
@@ -0,0 +1,23 @@
{{define "content"}}
<div class="error-page">
<div class="error-icon">
{{if eq .Code 404}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
{{else}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="64" height="64">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
{{end}}
</div>
<h1 class="error-code">{{.Code}}</h1>
<p class="error-title">{{.Title}}</p>
<p class="error-message">{{.Message}}</p>
<a href="/" class="error-home-link">Go to Dashboard</a>
</div>
{{end}}
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Settings — Gitea Mobile</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #e6edf3;
padding: 1rem;
padding-top: max(1rem, env(safe-area-inset-top));
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
.card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 1rem; margin-bottom: 1rem;
}
label { display: block; font-size: 0.875rem; color: #8b949e; margin-bottom: 0.5rem; }
input[type="text"], input[type="password"] {
width: 100%; padding: 0.5rem; font-size: 1rem;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
color: #e6edf3; margin-bottom: 1rem;
}
input:focus { outline: none; border-color: #58a6ff; }
button {
width: 100%; padding: 0.75rem; font-size: 1rem; font-weight: 600;
background: #238636; color: #fff; border: none; border-radius: 6px;
cursor: pointer;
}
button:active { background: #2ea043; }
.message {
padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem;
font-size: 0.875rem;
}
.message.success { background: #0d2818; border: 1px solid #238636; color: #3fb950; }
.message.error { background: #2d1117; border: 1px solid #da3633; color: #f85149; }
.message.info { background: #0c1d2e; border: 1px solid #1f6feb; color: #58a6ff; }
.hint { font-size: 0.75rem; color: #8b949e; margin-top: 0.25rem; margin-bottom: 1rem; }
.status { font-size: 0.875rem; color: #8b949e; }
.status .connected { color: #3fb950; }
.logout-btn {
background: #21262d; border: 1px solid #30363d; margin-top: 0.5rem;
}
.logout-btn:active { background: #30363d; }
</style>
</head>
<body>
<h1>Settings</h1>
{{if .Message}}
<div class="message {{.MessageType}}">{{.Message}}</div>
{{end}}
{{if .HasToken}}
<div class="card">
<p class="status">Status: <span class="connected">Connected</span></p>
<p class="hint">A Gitea API token is configured.</p>
<form method="POST" action="/settings">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Remove Token</button>
</form>
</div>
{{end}}
<div class="card">
<form method="POST" action="/settings">
<input type="hidden" name="action" value="save">
<label for="token">Gitea API Token</label>
<input type="password" id="token" name="token" placeholder="Enter your Gitea API token" required>
<p class="hint">Generate a token at your Gitea instance under Settings &rarr; Applications.</p>
<button type="submit">{{if .HasToken}}Update Token{{else}}Save Token{{end}}</button>
</form>
</div>
{{if .HasToken}}
<p style="text-align:center; margin-top:1rem;">
<a href="/" style="color:#58a6ff; text-decoration:none;">Back to Dashboard</a>
</p>
{{end}}
</body>
</html>
+106 -2
View File
@@ -1,4 +1,12 @@
/* Gitea Mobile — Mobile-first CSS (~5KB target) */
/* Gitea Mobile Mobile-first CSS
* Dark-mode-first: dark colors are the :root defaults.
* Light mode is applied via @media (prefers-color-scheme: light).
*
* Size note: The original ~5KB target was based on the initial Phase 1 scope.
* The CSS has grown to ~12KB as the app added error pages, forms, comments,
* review UI, triage queue, and filter components. All rules are in active use.
* Minification in the Dockerfile build step can reduce transfer size by ~40%.
*/
/* Reset */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -533,7 +541,7 @@ a:active {
}
}
/* Dark mode is default; light mode override if needed */
/* Dark mode is default; light mode override for prefers-color-scheme: light */
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #ffffff;
@@ -543,5 +551,101 @@ a:active {
--text-primary: #1f2328;
--text-secondary: #656d76;
--text-link: #0969da;
--accent-green: #1a7f37;
--accent-red: #cf222e;
--accent-yellow: #9a6700;
--accent-blue: #0969da;
--accent-purple: #8250df;
}
.message.success {
background: #dafbe1;
border-color: #1a7f37;
}
.message.error {
background: #ffebe9;
border-color: #cf222e;
}
.message.info {
background: #ddf4ff;
border-color: #0969da;
}
.btn-primary {
background: #1a7f37;
}
.btn-primary:active {
background: #116329;
}
.btn-danger {
background: #ffebe9;
border-color: #cf222e;
}
.type-issue {
background: rgba(9, 105, 218, 0.1);
border-color: rgba(9, 105, 218, 0.3);
}
.type-pull {
background: rgba(26, 127, 55, 0.1);
border-color: rgba(26, 127, 55, 0.3);
}
}
/* Error page */
.error-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
padding: var(--spacing-lg);
}
.error-icon {
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
}
.error-code {
font-size: 4rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: var(--spacing-sm);
}
.error-title {
font-size: var(--font-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.error-message {
font-size: var(--font-base);
color: var(--text-secondary);
margin-bottom: var(--spacing-lg);
max-width: 300px;
}
.error-home-link {
display: inline-block;
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--accent-blue);
color: #fff;
border-radius: var(--radius);
text-decoration: none;
font-size: var(--font-base);
font-weight: 500;
transition: opacity 0.15s;
}
.error-home-link:active {
opacity: 0.8;
}