Compare commits

..

12 Commits

Author SHA1 Message Date
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
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
agent-company feae2e19a1 feat: wire GITEA_TOKEN env var as auth fallback for single-user deployments
Update Auth middleware to accept a fallbackToken parameter. When no
per-user cookie token is present and GITEA_TOKEN is set in the
environment, the middleware uses the env token instead of redirecting
to /settings. Cookie tokens still take precedence over the fallback.

Add three new unit tests covering: fallback used when no cookie,
cookie takes precedence over fallback, and redirect when neither is set.

Closes leeworks-agents/gitea-mobile#125

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:04:55 +00:00
AI-Manager 417104c617 Merge pull request 'test: unit tests for ListOrgsAndRepos, CreateIssue, ListAllIssues, ListAllPullRequests' (#123) from feature/unit-tests-122-121 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 12:02:34 +00:00
agent-company d65676afe6 test: add unit tests for ListOrgsAndRepos, CreateIssue, ListAllIssues, ListAllPullRequests
Add comprehensive unit tests using mock HTTP servers for four key
aggregation methods in the Gitea client. Tests cover correct API
integration, caching behavior, sorting, state filtering, repo
filtering, pagination, and label handling.

Closes leeworks-agents/gitea-mobile#122
Closes leeworks-agents/gitea-mobile#121

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:05:34 +00:00
AI-Manager a0f786e894 Merge pull request 'feat: tablet 2-column grid layout for issue and PR lists' (#108) from feature/tablet-grid-layout-105 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 07:02:42 +00:00
AI-Manager 80aebe8e9f Merge pull request 'chore: add -race flag to CI test step' (#107) from fix/ci-runner-and-race-95-103 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 07:02:36 +00:00
agent-company b74e9de04d feat: implement tablet 2-column grid layout for issue and PR lists
Add grid layout at >= 640px breakpoint for #issue-list and #pull-list
containers, matching the existing .card-grid tablet behavior. Cards
render in a 2-column grid on tablet while maintaining single-column
on mobile.

Closes leeworks-agents/gitea-mobile#105

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:06:24 +00:00
8 changed files with 1153 additions and 115 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ func main() {
// Apply middleware chain: logging -> auth.
var handler http.Handler = mux
handler = middleware.Auth(cfg.SessionSecret)(handler)
handler = middleware.Auth(cfg.SessionSecret, cfg.GiteaToken)(handler)
handler = middleware.Logging()(handler)
slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL)
+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.
+871
View File
@@ -3,8 +3,10 @@ package gitea
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -374,3 +376,872 @@ func sortTriageQueue(queue []TriageItem) {
}
}
}
// --- Issue #122: Tests for ListOrgsAndRepos and CreateIssue ---
func TestListOrgsAndRepos(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/user/orgs":
orgs := []Org{
{Name: "org1", FullName: "Organization 1"},
{Name: "org2", FullName: "Organization 2"},
}
json.NewEncoder(w).Encode(orgs)
case "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a"},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b"},
}
json.NewEncoder(w).Encode(repos)
case "/api/v1/orgs/org2/repos":
repos := []Repo{
{ID: 3, Name: "repo-c", FullName: "org2/repo-c"},
}
json.NewEncoder(w).Encode(repos)
default:
t.Errorf("unexpected request path: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
result, err := c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("got %d orgs, want 2", len(result))
}
if len(result["org1"]) != 2 {
t.Errorf("org1 has %d repos, want 2", len(result["org1"]))
}
if len(result["org2"]) != 1 {
t.Errorf("org2 has %d repos, want 1", len(result["org2"]))
}
if result["org1"][0].Name != "repo-a" {
t.Errorf("org1 repos[0].Name = %q, want %q", result["org1"][0].Name, "repo-a")
}
if result["org2"][0].Name != "repo-c" {
t.Errorf("org2 repos[0].Name = %q, want %q", result["org2"][0].Name, "repo-c")
}
}
func TestListOrgsAndRepos_Cached(t *testing.T) {
orgCallCount := 0
repoCallCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/user/orgs":
orgCallCount++
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case "/api/v1/orgs/org1/repos":
repoCallCount++
json.NewEncoder(w).Encode([]Repo{{ID: 1, Name: "repo1", FullName: "org1/repo1"}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
// First call populates cache.
_, err := c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("first call: %v", err)
}
// Second call should use cached orgs and repos (ListOrgs and ListOrgRepos both cache).
_, err = c.ListOrgsAndRepos(context.Background(), "test-token")
if err != nil {
t.Fatalf("second call: %v", err)
}
if orgCallCount != 1 {
t.Errorf("org endpoint called %d times, want 1 (cached)", orgCallCount)
}
if repoCallCount != 1 {
t.Errorf("repo endpoint called %d times, want 1 (cached)", repoCallCount)
}
}
func TestCreateIssue(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" {
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)
}
if body["title"] != "Test Issue" {
t.Errorf("expected title='Test Issue', got %q", body["title"])
}
if body["body"] != "Issue body here" {
t.Errorf("expected body='Issue body here', got %q", body["body"])
}
issue := map[string]interface{}{
"id": 1,
"number": 42,
"title": body["title"],
"body": body["body"],
"state": "open",
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-03-28T00:00:00Z",
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(issue)
}))
defer server.Close()
c := NewClient(server.URL)
// Pre-populate cache to verify invalidation.
c.setCache("issues-org1", "should-be-invalidated")
issue, err := c.CreateIssue(context.Background(), "test-token", "owner1", "repo1", "Test Issue", "Issue body here", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if issue.Title != "Test Issue" {
t.Errorf("issue.Title = %q, want %q", issue.Title, "Test Issue")
}
if issue.Number != 42 {
t.Errorf("issue.Number = %d, want 42", issue.Number)
}
if issue.RepoOwner != "owner1" {
t.Errorf("issue.RepoOwner = %q, want %q", issue.RepoOwner, "owner1")
}
if issue.RepoName != "repo1" {
t.Errorf("issue.RepoName = %q, want %q", issue.RepoName, "repo1")
}
// Verify cache was invalidated.
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after CreateIssue")
}
}
func TestCreateIssue_WithLabels(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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"]
if !ok {
t.Error("expected labels in request body")
}
labelSlice, ok := labels.([]interface{})
if !ok || len(labelSlice) != 2 {
t.Errorf("expected 2 labels, got %v", labels)
}
issue := map[string]interface{}{
"id": 2,
"number": 43,
"title": body["title"],
"body": body["body"],
"state": "open",
"created_at": "2026-03-28T00:00:00Z",
"updated_at": "2026-03-28T00:00:00Z",
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(issue)
}))
defer server.Close()
c := NewClient(server.URL)
issue, err := c.CreateIssue(context.Background(), "test-token", "owner1", "repo1", "Labeled Issue", "body", []int64{10, 20})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if issue.Number != 43 {
t.Errorf("issue.Number = %d, want 43", issue.Number)
}
}
// --- Issue #121: Tests for ListAllIssues and ListAllPullRequests ---
// newFanOutServer creates a mock HTTP server that serves orgs, repos, issues, and PRs
// for testing the fan-out aggregation functions.
func newFanOutServer(t *testing.T) *httptest.Server {
t.Helper()
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
}
json.NewEncoder(w).Encode(repos)
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
issues := []Issue{
{ID: 1, Number: 1, Title: "Issue A1", State: "open", UpdatedAt: now.Add(-1 * time.Hour)},
{ID: 2, Number: 2, Title: "Issue A2", State: "open", UpdatedAt: now.Add(-3 * time.Hour)},
}
json.NewEncoder(w).Encode(issues)
case r.URL.Path == "/api/v1/repos/org1/repo-b/issues":
issues := []Issue{
{ID: 3, Number: 1, Title: "Issue B1", State: "open", UpdatedAt: now.Add(-2 * time.Hour)},
}
json.NewEncoder(w).Encode(issues)
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
prs := []PullRequest{
{ID: 10, Number: 5, Title: "PR A1", State: "open", UpdatedAt: now.Add(-30 * time.Minute)},
}
json.NewEncoder(w).Encode(prs)
case r.URL.Path == "/api/v1/repos/org1/repo-b/pulls":
prs := []PullRequest{
{ID: 11, Number: 6, Title: "PR B1", State: "open", UpdatedAt: now.Add(-10 * time.Minute)},
{ID: 12, Number: 7, Title: "PR B2", State: "open", UpdatedAt: now.Add(-1 * time.Hour)},
}
json.NewEncoder(w).Encode(prs)
default:
t.Errorf("unexpected request path: %s", r.URL.Path)
http.NotFound(w, r)
}
}))
}
func TestListAllIssues_Sorting(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
// Pre-populate org repos cache to avoid needing the /user/orgs endpoint.
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Issues) != 3 {
t.Fatalf("got %d issues, want 3", len(result.Issues))
}
// Should be sorted by UpdatedAt descending (newest first).
// Issue A1 (-1h), Issue B1 (-2h), Issue A2 (-3h).
expected := []string{"Issue A1", "Issue B1", "Issue A2"}
for i, title := range expected {
if result.Issues[i].Title != title {
t.Errorf("issues[%d].Title = %q, want %q", i, result.Issues[i].Title, title)
}
}
}
func TestListAllIssues_StateFilter(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
repos := []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
}
json.NewEncoder(w).Encode(repos)
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]Issue{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "closed", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "closed" {
t.Errorf("state query param = %q, want %q", stateReceived, "closed")
}
}
func TestListAllIssues_DefaultState(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]Issue{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "open" {
t.Errorf("default state = %q, want %q", stateReceived, "open")
}
}
func TestListAllIssues_RepoFilter(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "repo-a")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Only issues from repo-a should be returned.
if len(result.Issues) != 2 {
t.Fatalf("got %d issues, want 2 (only from repo-a)", len(result.Issues))
}
for _, issue := range result.Issues {
if issue.RepoName != "repo-a" {
t.Errorf("issue %q has RepoName=%q, want repo-a", issue.Title, issue.RepoName)
}
}
}
func TestListAllIssues_Pagination(t *testing.T) {
now := time.Date(2026, 3, 28, 12, 0, 0, 0, time.UTC)
// Create enough issues to test pagination (PageSize = 20).
var issues []Issue
for i := 0; i < 25; i++ {
issues = append(issues, Issue{
ID: int64(i + 1),
Number: int64(i + 1),
Title: fmt.Sprintf("Issue %d", i+1),
State: "open",
UpdatedAt: now.Add(time.Duration(-i) * time.Hour),
})
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/issues":
json.NewEncoder(w).Encode(issues)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
// Page 1: should have 20 items with HasMore=true.
page1, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(page1.Issues) != 20 {
t.Errorf("page 1: got %d issues, want 20", len(page1.Issues))
}
if !page1.HasMore {
t.Error("page 1: HasMore should be true")
}
// Page 2: should have 5 items with HasMore=false.
page2, err := c.ListAllIssues(context.Background(), "test-token", []string{"org1"}, "open", 2, "", "")
if err != nil {
t.Fatalf("page 2: %v", err)
}
if len(page2.Issues) != 5 {
t.Errorf("page 2: got %d issues, want 5", len(page2.Issues))
}
if page2.HasMore {
t.Error("page 2: HasMore should be false")
}
}
func TestListAllPullRequests_Sorting(t *testing.T) {
server := newFanOutServer(t)
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
{ID: 2, Name: "repo-b", FullName: "org1/repo-b", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
result, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Pulls) != 3 {
t.Fatalf("got %d PRs, want 3", len(result.Pulls))
}
// Should be sorted by UpdatedAt descending.
// PR B1 (-10m), PR A1 (-30m), PR B2 (-1h).
expected := []string{"PR B1", "PR A1", "PR B2"}
for i, title := range expected {
if result.Pulls[i].Title != title {
t.Errorf("pulls[%d].Title = %q, want %q", i, result.Pulls[i].Title, title)
}
}
}
func TestListAllPullRequests_StateFilter(t *testing.T) {
stateReceived := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
stateReceived = r.URL.Query().Get("state")
json.NewEncoder(w).Encode([]PullRequest{})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
_, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "closed", 1, "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stateReceived != "closed" {
t.Errorf("state query param = %q, want %q", stateReceived, "closed")
}
}
// --- 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)
var prs []PullRequest
for i := 0; i < 25; i++ {
prs = append(prs, PullRequest{
ID: int64(i + 1),
Number: int64(i + 1),
Title: fmt.Sprintf("PR %d", i+1),
State: "open",
UpdatedAt: now.Add(time.Duration(-i) * time.Hour),
})
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/org1/repos":
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case r.URL.Path == "/api/v1/repos/org1/repo-a/pulls":
json.NewEncoder(w).Encode(prs)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("repos-org1", []Repo{
{ID: 1, Name: "repo-a", FullName: "org1/repo-a", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
page1, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 1, "", "")
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(page1.Pulls) != 20 {
t.Errorf("page 1: got %d PRs, want 20", len(page1.Pulls))
}
if !page1.HasMore {
t.Error("page 1: HasMore should be true")
}
page2, err := c.ListAllPullRequests(context.Background(), "test-token", []string{"org1"}, "open", 2, "", "")
if err != nil {
t.Fatalf("page 2: %v", err)
}
if len(page2.Pulls) != 5 {
t.Errorf("page 2: got %d PRs, want 5", len(page2.Pulls))
}
if page2.HasMore {
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)
}
}
}
+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)
}
}
+11 -1
View File
@@ -23,9 +23,12 @@ func TokenFromContext(ctx context.Context) string {
}
// Auth returns middleware that checks for a valid token cookie.
// If no cookie token is found and fallbackToken is non-empty, the fallback
// token is used instead (useful for single-user or service-account deployments
// where GITEA_TOKEN is set in the environment).
// Unauthenticated requests are redirected to the settings page.
// The /health, /settings, and /static/ paths are exempt from auth.
func Auth(sessionSecret string) func(http.Handler) http.Handler {
func Auth(sessionSecret, fallbackToken string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for exempt paths.
@@ -37,6 +40,13 @@ func Auth(sessionSecret string) func(http.Handler) http.Handler {
token, err := auth.GetToken(r, sessionSecret)
if err != nil || token == "" {
// Fall back to environment token if available.
if fallbackToken != "" {
slog.Debug("using fallback token from environment", "path", path)
ctx := context.WithValue(r.Context(), TokenContextKey, fallbackToken)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
slog.Debug("unauthenticated request, redirecting to settings", "path", path, "error", err)
http.Redirect(w, r, "/settings", http.StatusSeeOther)
return
+73 -4
View File
@@ -11,7 +11,7 @@ import (
const testSecret = "test-secret-that-is-at-least-32-chars-long"
func TestAuth_HealthBypass(t *testing.T) {
handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -25,7 +25,7 @@ func TestAuth_HealthBypass(t *testing.T) {
}
func TestAuth_SettingsBypass(t *testing.T) {
handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -39,7 +39,7 @@ func TestAuth_SettingsBypass(t *testing.T) {
}
func TestAuth_RedirectWithoutToken(t *testing.T) {
handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -57,7 +57,7 @@ func TestAuth_RedirectWithoutToken(t *testing.T) {
func TestAuth_PassWithToken(t *testing.T) {
called := false
handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
token := TokenFromContext(r.Context())
if token != "my-token" {
@@ -83,3 +83,72 @@ func TestAuth_PassWithToken(t *testing.T) {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestAuth_FallbackToken_UsedWhenNoCookie(t *testing.T) {
called := false
handler := Auth(testSecret, "env-fallback-token")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
token := TokenFromContext(r.Context())
if token != "env-fallback-token" {
t.Errorf("token = %q, want %q", token, "env-fallback-token")
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !called {
t.Error("next handler was not called with fallback token")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestAuth_FallbackToken_CookieTakesPrecedence(t *testing.T) {
called := false
handler := Auth(testSecret, "env-fallback-token")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
token := TokenFromContext(r.Context())
if token != "cookie-token" {
t.Errorf("token = %q, want %q (cookie should take precedence over fallback)", token, "cookie-token")
}
w.WriteHeader(http.StatusOK)
}))
// Set a cookie token.
cookieW := httptest.NewRecorder()
auth.SetTokenCookie(cookieW, "cookie-token", testSecret, false)
cookie := cookieW.Result().Cookies()[0]
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !called {
t.Error("next handler was not called")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestAuth_NoFallbackToken_RedirectsWithoutCookie(t *testing.T) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/issues", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther)
}
if loc := w.Header().Get("Location"); loc != "/settings" {
t.Errorf("Location = %q, want %q", loc, "/settings")
}
}
+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>
+6 -2
View File
@@ -510,13 +510,17 @@ a:active {
max-width: 960px;
}
.card-grid {
.card-grid,
#issue-list,
#pull-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
.card-grid .card {
.card-grid .card,
#issue-list .card,
#pull-list .card {
margin-bottom: 0;
}
}