Compare commits

..

8 Commits

Author SHA1 Message Date
agent-company af8e705919 docs: add README.md with project overview, dev setup, and deployment guide
Covers tech stack, project structure, local development with nix develop
and air live reload, environment variables, testing, container build, and
deployment pointer to Talos repo manifests.

Closes leeworks-agents/gitea-mobile#148

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:05:27 +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 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
8 changed files with 1636 additions and 21 deletions
+116
View File
@@ -0,0 +1,116 @@
# Gitea Mobile
A mobile-first Progressive Web App (PWA) for managing Gitea issues and pull requests across multiple repositories and organizations from an iPhone. Built with Go, HTMX, and hand-rolled CSS -- no JavaScript frameworks, no build step, no node_modules.
## Tech Stack
| Layer | Choice |
|-------|--------|
| Backend | Go + Gitea SDK (`code.gitea.io/sdk/gitea`) |
| Frontend | HTMX + Go `html/template` + hand-rolled CSS |
| Container | Multi-stage Dockerfile -> distroless (~15MB) |
| Deployment | Kustomize manifests + FluxCD GitOps |
## Project Structure
```
/
├── cmd/server/main.go # entrypoint
├── internal/
│ ├── config/config.go # env-based configuration
│ ├── gitea/client.go # Gitea SDK wrapper / aggregation layer
│ ├── handlers/ # HTTP handlers (issues, PRs, triage, settings)
│ ├── auth/ # cookie-based token auth
│ ├── middleware/ # auth middleware, logging
│ └── templates/ # Go html/template files (for HTMX)
├── static/ # CSS, JS (htmx.min.js), icons, manifest
├── .gitea/workflows/build.yaml # CI pipeline (Gitea Actions)
├── Dockerfile
├── flake.nix # Nix dev shell with Go + air
└── go.mod
```
## Local Development
### Prerequisites
- [Nix](https://nixos.org/download/) with flakes enabled, **or** Go 1.22+
- A Gitea instance with an API token
### Quick Start
```bash
# Enter the Nix dev shell (provides Go, gopls, air)
nix develop
# Set required environment variables
export GITEA_URL=https://gitea.leeworks.dev
export SESSION_SECRET=$(openssl rand -hex 32)
# Optional: set a default API token
export GITEA_TOKEN=your-gitea-api-token
# Start the server with live reload
air
```
If you are not using Nix, install Go 1.22+ and [air](https://github.com/air-verse/air) manually, then run the same commands above starting from the export lines.
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `GITEA_URL` | Yes | -- | Base URL of the Gitea instance |
| `SESSION_SECRET` | Yes | -- | HMAC key for signing session cookies (min 32 chars) |
| `GITEA_TOKEN` | No | -- | Default API token (users can set their own via the settings page) |
| `LISTEN_ADDR` | No | `:8080` | Server listen address |
### Live Reload with Air
The dev shell includes [air](https://github.com/air-verse/air) for automatic recompilation on file changes. Configuration is in `.air.toml`. Air watches `.go` and `.html` files under `cmd/`, `internal/`, and `static/` and rebuilds/restarts the server automatically.
## Running Tests
```bash
# Run all tests
go test ./...
# Run tests with race detection
go test -race ./...
```
## Building the Container
```bash
# Build the Docker image
docker build -t gitea-mobile .
# Run locally
docker run -p 8080:8080 \
-e GITEA_URL=https://gitea.leeworks.dev \
-e SESSION_SECRET=$(openssl rand -hex 32) \
gitea-mobile
```
The Dockerfile uses a multi-stage build: Go binary compiled in an Alpine builder stage, then copied into a distroless image (~15MB final size).
## Deployment
Kubernetes manifests for this app live in the Talos cluster repo under `testing1/first-cluster/apps/gitea-mobile/`. FluxCD syncs from that repo and handles automated image updates via `ImagePolicy` annotations.
Key deployment resources:
- `deployment.yaml` -- Pod spec with health checks
- `service.yaml` -- ClusterIP service on port 8080
- `ingressroute.yaml` -- Traefik IngressRoute for `gitea-mobile.testing.leeworks.dev`
- `kustomization.yaml` -- Kustomize overlay
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/your-feature`
3. Make your changes and add tests
4. Run `go test -race ./...` to verify
5. Commit with a clear message referencing the issue number
6. Push to your fork and open a pull request
All PRs target the fork (`leeworks-agents/gitea-mobile`), not the upstream repo.
+91 -20
View File
@@ -8,8 +8,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"math"
"net/http" "net/http"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -27,6 +30,11 @@ type Client struct {
maxConcurrent int maxConcurrent int
// cacheTTL controls how long cache entries remain valid. // cacheTTL controls how long cache entries remain valid.
cacheTTL time.Duration 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 { type cacheEntry struct {
@@ -129,39 +137,102 @@ func NewClient(baseURL string) *Client {
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
cache: make(map[string]*cacheEntry), cache: make(map[string]*cacheEntry),
maxConcurrent: 5, maxConcurrent: 5,
cacheTTL: 30 * time.Second, cacheTTL: 30 * time.Second,
maxRetries: 3,
baseRetryDelay: 1 * time.Second,
} }
} }
// doRequest performs an authenticated HTTP request to the Gitea API. // 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) { func (c *Client) doRequest(ctx context.Context, token, method, path string, body io.Reader) (*http.Response, error) {
url := c.baseURL + "/api/v1" + path url := c.baseURL + "/api/v1" + path
req, err := http.NewRequestWithContext(ctx, method, url, body) // Read the body once so we can replay it on retries.
if err != nil { var bodyBytes []byte
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Accept", "application/json")
if body != nil { 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) var lastErr error
if err != nil { for attempt := 0; attempt <= c.maxRetries; attempt++ {
return nil, fmt.Errorf("executing request: %w", err) // 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 { return nil, lastErr
defer resp.Body.Close() }
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
}
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. // getFromCache returns cached data if still valid.
+158
View File
@@ -1298,3 +1298,161 @@ func TestListAllPullRequests_Pagination(t *testing.T) {
t.Error("page 2: HasMore should be false") 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. // Dashboard handles GET / — the triage queue.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
// Only handle exact root path. // Only handle exact root path.
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) h.ErrorNotFound(w, r)
return 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 { func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr) return len(s) >= len(substr) && searchString(s, substr)
} }
File diff suppressed because it is too large Load Diff
+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}}
+53
View File
@@ -545,3 +545,56 @@ a:active {
--text-link: #0969da; --text-link: #0969da;
} }
} }
/* 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;
}