Compare commits

..

36 Commits

Author SHA1 Message Date
agent-company 3d1775a37b feat: add MergePull() method to Gitea client
Add MergePull() that calls POST /repos/{owner}/{repo}/pulls/{index}/merge
with the specified merge style (merge, rebase, rebase-merge, squash).
Defaults to "merge" if no style specified. Includes unit tests for
success, default style, and error cases.

This is a prerequisite for #177 (merge PR button in UI) and #206
(POST /pulls merge handler).

Closes leeworks-agents/gitea-mobile#187

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:13:56 +00:00
AI-Manager baf829349c Merge pull request 'docs: fix SMOKE_TEST.md triage route (/triage -> /)' (#159) from fix/smoke-test-triage-route-157 into master
Build and Push / test (push) Failing after 1m35s
Build and Push / build (push) Has been skipped
2026-03-29 03:04:47 +00:00
agent-company 3145acc423 docs: fix SMOKE_TEST.md triage route reference (/triage -> /)
The triage queue is served at / (the dashboard), not a separate /triage
route. Update Step 7 and the expected results summary table to reference
the correct route, consistent with ROADMAP.md and handlers.go.

Closes leeworks-agents/gitea-mobile#157

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 03:04:07 +00:00
AI-Manager ce3fc36835 Merge pull request 'chore: add go vet step to CI workflow' (#156) from chore/add-go-vet-ci-154 into master
Build and Push / test (push) Failing after 14m51s
Build and Push / build (push) Has been cancelled
2026-03-29 01:03:01 +00:00
agent-company c267bc86a8 chore: add go vet step to CI workflow before tests
Add a `go vet ./...` step that runs before `go test -race ./...` in the
CI pipeline. This catches format string errors, unreachable code, and
other static analysis issues early.

Verified locally: `go vet ./...` exits 0 with no warnings.

Closes leeworks-agents/gitea-mobile#154

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:06:43 +00:00
AI-Manager c790a7236c Merge pull request 'chore: improve light mode CSS and document size rationale' (#152) from feature/dark-mode-validation-119 into master
Build and Push / test (push) Failing after 8s
Build and Push / build (push) Has been skipped
2026-03-28 22:03:54 +00:00
AI-Manager 0ef2184204 Merge pull request 'docs: add post-deployment smoke test runbook' (#151) from feature/smoke-test-runbook-116 into master
Build and Push / build (push) Has been cancelled
Build and Push / test (push) Has been cancelled
2026-03-28 22:03:46 +00:00
AI-Manager ca3564a1ec Merge pull request 'chore: add .air.toml for live reload dev workflow' (#150) from feature/air-toml-109 into master
Build and Push / build (push) Has been cancelled
Build and Push / test (push) Has been cancelled
2026-03-28 22:03:32 +00:00
AI-Manager e6ca9a078d Merge pull request 'docs: add README.md with project overview and dev setup' (#149) from feature/readme-148 into master
Build and Push / test (push) Has started running
Build and Push / build (push) Has been cancelled
2026-03-28 22:03:28 +00:00
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
agent-company 7d90b5eb4b docs: add post-deployment smoke test runbook
Covers pre-conditions, health check, TLS verification, auth flow, core
functionality (issues, PRs, triage), write operations, PWA behavior on
iPhone Safari, and rollback procedures.

Closes leeworks-agents/gitea-mobile#116

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:05:32 +00:00
agent-company faf5fc1797 chore: add .air.toml for live reload dev workflow
Configures air to build ./cmd/server, watch .go/.html/.css/.js files
under the project tree, and auto-restart on changes. Excludes test
files and vendor directories.

Closes leeworks-agents/gitea-mobile#109

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:05:30 +00:00
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 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
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
agent-company c51ec5f752 chore: add -race flag to CI test step for concurrency bug detection
The aggregation layer uses sync.RWMutex and errgroup for concurrent
API fan-out. Enable the Go race detector in CI to catch data races
early.

Closes leeworks-agents/gitea-mobile#103

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 06:05:37 +00:00
AI-Manager 5c54d587aa Merge pull request 'feat: add review status and merge indicator to PR list' (#102) from feature/pr-status-icons-97 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 05:03:26 +00:00
AI-Manager c9e883da87 Merge pull request 'feat: display assignee avatar in issue list rows' (#101) from feature/assignee-avatar-98 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 05:03:13 +00:00
agent-company b0c060efae feat: add review status icon and merge status indicator to PR list rows
Add per-PR review state aggregation by fetching reviews concurrently
via the existing semaphore pattern. Display review status (approved,
changes requested, awaiting) and merge status (ready/conflicts) as
compact badges in each PR card row.

- Add ReviewState field to PullRequest struct
- Add GetPullReviewState() and EnrichPullsWithReviewState() to client
- Call enrichment in ListPulls handler after fetching PRs
- Update pulls template with review and merge badges
- Add CSS for .review-badge and .merge-badge classes

Closes leeworks-agents/gitea-mobile#97

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 03:07:52 +00:00
17 changed files with 3217 additions and 122 deletions
+44
View File
@@ -0,0 +1,44 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd/server"
delay = 500
exclude_dir = ["assets", "tmp", "vendor", "testdata", ".git", "node_modules"]
exclude_file = []
exclude_regex = ["_test\\.go$"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "html", "css", "js"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false
keep_scroll = true
+4 -1
View File
@@ -15,8 +15,11 @@ jobs:
with:
go-version: '1.22'
- name: Vet
run: go vet ./...
- name: Run tests
run: go test ./...
run: go test -race ./...
build:
runs-on: ubuntu-latest
+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.
+148
View File
@@ -0,0 +1,148 @@
# Post-Deployment Smoke Test Runbook
Smoke test procedure for verifying gitea-mobile after deployment to the Talos cluster.
## Pre-conditions
Before running the smoke test, confirm:
- [ ] FluxCD has reconciled the latest manifests: `flux get kustomizations -n flux-system`
- [ ] The gitea-mobile pod is Running: `kubectl get pods -n gitea-mobile`
- [ ] The IngressRoute is active: `kubectl get ingressroute -n gitea-mobile`
- [ ] DNS resolves `gitea-mobile.testing.leeworks.dev` to the cluster ingress
## Step 1: Pod Health
```bash
# Verify the pod is running and ready
kubectl get pods -n gitea-mobile
# Expected: STATUS=Running, READY=1/1
# Check pod logs for startup errors
kubectl logs -n gitea-mobile deployment/gitea-mobile --tail=20
# Expected: JSON log line with "server starting" message
```
## Step 2: Health Endpoint
```bash
# Hit the health check endpoint from inside the cluster
kubectl exec -n gitea-mobile deployment/gitea-mobile -- wget -qO- http://localhost:8080/health
# Expected: HTTP 200
# Hit the health check endpoint from outside the cluster
curl -s -o /dev/null -w "%{http_code}" https://gitea-mobile.testing.leeworks.dev/health
# Expected: 200
```
## Step 3: TLS and Ingress
```bash
# Verify TLS certificate is valid
curl -vI https://gitea-mobile.testing.leeworks.dev 2>&1 | grep "SSL certificate"
# Expected: valid certificate from Let's Encrypt or cluster CA
# Verify the app responds with HTML
curl -s https://gitea-mobile.testing.leeworks.dev | head -5
# Expected: HTML document with <html> tag
```
## Step 4: Authentication Flow
1. Open `https://gitea-mobile.testing.leeworks.dev` in a browser
2. Navigate to the Settings page (`/settings`)
3. Enter a valid Gitea API token
4. Submit the form
5. **Expected**: Token is saved, page confirms success
6. Navigate back to the Issues tab
7. **Expected**: Issues load from the Gitea API using the saved token
## Step 5: Core Functionality -- Issues
1. Navigate to the Issues tab (`/issues`)
2. **Expected**: Cross-org issues load and display with titles, labels, and timestamps
3. Tap on an issue to expand details
4. **Expected**: Issue body renders correctly
5. Use the filter dropdown to filter by repo or label
6. **Expected**: List updates via HTMX without full page reload
## Step 6: Core Functionality -- Pull Requests
1. Navigate to the PRs tab (`/pulls`)
2. **Expected**: Pull requests load with review status icons
3. Tap on a PR to see details
4. **Expected**: PR diff summary or review status displays correctly
## Step 7: Core Functionality -- Dashboard / Triage Queue
1. Navigate to the Dashboard/Triage tab (`/`)
2. **Expected**: Unassigned issues and PRs awaiting review appear sorted by priority
## Step 8: Create Issue (Write Operation)
1. Navigate to the new issue form
2. Fill in title: `[smoke-test] Automated verification`
3. Fill in body: `This issue was created during smoke testing. Safe to close.`
4. Submit the form
5. **Expected**: Issue is created successfully in Gitea
6. Verify in Gitea web UI that the issue exists
7. Close and delete the test issue after verification
## Step 9: Apply Label (Write Operation)
1. On any test issue, attempt to apply a label
2. **Expected**: Label is applied via the Gitea API and reflected in the UI
## Step 10: PWA / iPhone Safari
1. Open `https://gitea-mobile.testing.leeworks.dev` on iPhone Safari
2. **Expected**: App loads with mobile-optimized layout, no horizontal scroll
3. Tap "Add to Home Screen" from the Safari share menu
4. **Expected**: App icon appears on the home screen (apple-touch-icon)
5. Launch from the home screen
6. **Expected**: App opens in standalone mode (no Safari browser chrome)
7. Verify bottom navigation does not overlap with iPhone home indicator
8. Toggle device dark mode in Settings
9. **Expected**: App switches between dark and light themes via `prefers-color-scheme`
10. See issue #93 for the full PWA validation checklist
## Expected Results Summary
| Step | Check | Expected |
|------|-------|----------|
| 1 | Pod status | Running, Ready 1/1 |
| 2 | `/health` | HTTP 200 |
| 3 | TLS | Valid cert, HTML response |
| 4 | Auth | Token saved, API calls work |
| 5 | Issues | List loads, filter works |
| 6 | PRs | List loads with review status |
| 7 | Dashboard/Triage | Queue displays correctly at `/` |
| 8 | Create issue | Issue created in Gitea |
| 9 | Apply label | Label applied via API |
| 10 | PWA | Standalone mode, safe areas, dark mode |
## Rollback Procedure
If the deployment is broken or the app is not functioning:
```bash
# Roll back to the previous deployment revision
kubectl rollout undo deployment/gitea-mobile -n gitea-mobile
# Verify the rollback
kubectl rollout status deployment/gitea-mobile -n gitea-mobile
# Expected: "deployment successfully rolled out"
# Check that the previous image tag is running
kubectl get deployment gitea-mobile -n gitea-mobile -o jsonpath='{.spec.template.spec.containers[0].image}'
```
If FluxCD keeps reconciling back to the broken version, suspend reconciliation temporarily:
```bash
# Suspend Flux reconciliation
flux suspend kustomization gitea-mobile -n flux-system
# After fixing the issue, resume
flux resume kustomization gitea-mobile -n flux-system
```
+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)
+196 -22
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 {
@@ -105,8 +113,9 @@ type PullRequest struct {
Deletions int `json:"deletions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
RepoOwner string `json:"-"` // populated after fetch
RepoName string `json:"-"` // populated after fetch
RepoOwner string `json:"-"` // populated after fetch
RepoName string `json:"-"` // populated after fetch
ReviewState string `json:"-"` // aggregated review state: "approved", "changes_requested", "pending", or ""
}
// TriageItem represents an item in the triage queue.
@@ -128,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.
@@ -873,6 +945,40 @@ func (c *Client) SetIssueState(ctx context.Context, token, owner, repo string, i
return nil
}
// MergePull merges a pull request using the specified merge style.
// Valid styles: "merge", "rebase", "rebase-merge", "squash".
// If style is empty, defaults to "merge".
func (c *Client) MergePull(ctx context.Context, token, owner, repo string, index int64, style, title, message string) error {
if style == "" {
style = "merge"
}
payload := map[string]string{
"Do": style,
}
if title != "" {
payload["merge_message_field"] = title
}
if message != "" {
payload["merge_message_field"] = message
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshaling merge request: %w", err)
}
path := fmt.Sprintf("/repos/%s/%s/pulls/%d/merge", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(jsonData)))
if err != nil {
return fmt.Errorf("merging pull request: %w", err)
}
resp.Body.Close()
c.InvalidateAll()
return nil
}
// AddComment creates a comment on an issue and returns the created Comment.
func (c *Client) AddComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) {
return c.PostComment(ctx, token, owner, repo, index, body)
@@ -944,6 +1050,74 @@ func (c *Client) RenderMarkdown(ctx context.Context, token, text string) (string
return string(rendered), nil
}
// Review represents a single review on a pull request.
type Review struct {
ID int64 `json:"id"`
Body string `json:"body"`
State string `json:"state"` // "APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING"
User struct {
Login string `json:"login"`
} `json:"user"`
}
// GetPullReviewState fetches reviews for a PR and returns the aggregate state.
// Priority: changes_requested > approved > pending > "" (no reviews).
func (c *Client) GetPullReviewState(ctx context.Context, token, owner, repo string, index int64) string {
path := fmt.Sprintf("/repos/%s/%s/pulls/%d/reviews?limit=50", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return ""
}
defer resp.Body.Close()
var reviews []Review
if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil {
return ""
}
if len(reviews) == 0 {
return ""
}
// Aggregate: last non-comment review per user wins, then pick the "worst" state.
userState := make(map[string]string)
for _, r := range reviews {
switch r.State {
case "APPROVED", "REQUEST_CHANGES":
userState[r.User.Login] = r.State
}
}
if len(userState) == 0 {
return "pending"
}
for _, s := range userState {
if s == "REQUEST_CHANGES" {
return "changes_requested"
}
}
return "approved"
}
// EnrichPullsWithReviewState fetches review state for each PR concurrently.
func (c *Client) EnrichPullsWithReviewState(ctx context.Context, token string, pulls []PullRequest) {
sem := make(chan struct{}, c.maxConcurrent)
var wg sync.WaitGroup
for i := range pulls {
wg.Add(1)
go func(idx int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
pulls[idx].ReviewState = c.GetPullReviewState(ctx, token, pulls[idx].RepoOwner, pulls[idx].RepoName, pulls[idx].Number)
}(i)
}
wg.Wait()
}
// priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int {
for _, l := range labels {
File diff suppressed because it is too large Load Diff
+52 -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
}
@@ -412,6 +459,10 @@ func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
if result.HasMore {
data.NextPage = page + 1
}
// Enrich PRs with review state for status icons.
if len(data.Pulls) > 0 {
h.Client.EnrichPullsWithReviewState(r.Context(), token, data.Pulls)
}
}
}
+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)
}
}
+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")
}
}
+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}}
+7 -1
View File
@@ -12,7 +12,13 @@
{{end}}
<span class="diff-add">+{{.Additions}}</span>
<span class="diff-del">-{{.Deletions}}</span>
{{if .Mergeable}}<span style="color:var(--accent-green);font-size:0.7rem;">mergeable</span>{{end}}
{{if eq .ReviewState "approved"}}<span class="review-badge review-approved" title="Approved">&#10003;</span>
{{else if eq .ReviewState "changes_requested"}}<span class="review-badge review-changes" title="Changes requested">&#10007;</span>
{{else if eq .ReviewState "pending"}}<span class="review-badge review-pending" title="Awaiting review">&#9202;</span>
{{end}}
{{if .Mergeable}}<span class="merge-badge merge-ready" title="Ready to merge">&#9654; Ready</span>
{{else}}<span class="merge-badge merge-conflicts" title="Has conflicts or not mergeable">Conflicts</span>
{{end}}
</div>
</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>
+135 -4
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; }
@@ -419,6 +427,29 @@ a:active {
vertical-align: middle;
}
/* Review status badges */
.review-badge {
font-size: 0.7rem;
font-weight: 600;
padding: 1px 4px;
border-radius: var(--radius-pill);
vertical-align: middle;
}
.review-approved { color: var(--accent-green); }
.review-changes { color: var(--accent-red); }
.review-pending { color: var(--accent-yellow); }
/* Merge status badges */
.merge-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 1px 5px;
border-radius: var(--radius-pill);
vertical-align: middle;
}
.merge-ready { color: var(--accent-green); border: 1px solid var(--accent-green); }
.merge-conflicts { color: var(--accent-red); border: 1px solid var(--accent-red); }
/* Empty state */
.empty {
text-align: center;
@@ -487,13 +518,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;
}
}
@@ -506,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;
@@ -516,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;
}