Covers three phases: Go backend with Gitea SDK aggregation layer, HTMX mobile-first frontend with PWA support, and Docker/Kubernetes deployment to Talos via FluxCD.
12 KiB
Gitea Mobile — Roadmap
A mobile-first PWA wrapping the Gitea API for managing issues and pull requests across multiple repos and organizations from an iPhone.
Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Backend | Go + Gitea SDK (code.gitea.io/sdk/gitea) |
Native SDK covers every API surface, single static binary, tiny image |
| Frontend | HTMX + Go html/template + hand-rolled CSS |
Zero client-side state, no build step, no node_modules, ~14KB JS |
| Container | Multi-stage Dockerfile → distroless (~15MB) | Minimal attack surface, fast pull |
| Deployment | Kustomize manifests + FluxCD GitOps | Matches existing Talos cluster patterns |
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.go
│ │ ├── pulls.go
│ │ ├── triage.go
│ │ └── auth.go
│ ├── middleware/ # auth middleware, logging
│ └── templates/ # Go html/template files (for HTMX)
├── static/ # CSS, JS (htmx.min.js), icons, manifest
├── Dockerfile
├── flake.nix
└── go.mod
Phase 1: Backend
1.1 — Project Scaffolding
- Initialize Go module:
go mod init gitea.leeworks.dev/0xwheatyz/gitea-mobile - Create
flake.nixwith Go 1.22+ toolchain andairfor live reload - Set up directory structure per the project layout above
1.2 — Configuration
All config via environment variables (12-factor):
| Variable | Purpose | Default |
|---|---|---|
GITEA_URL |
Base URL of the Gitea instance | required |
GITEA_TOKEN |
API token (or per-user via cookie) | optional |
LISTEN_ADDR |
Server listen address | :8080 |
SESSION_SECRET |
HMAC key for signing session cookies | required |
1.3 — Authentication
v1: Token-in-cookie (simple, recommended start)
- User enters their Gitea API token once on a settings page
- Token stored in a signed, encrypted HTTP-only cookie
- All API calls use the user's token — respects Gitea's permission model
- Cookies set with
HttpOnly,Secure,SameSite=Strict
v2 (future): Authentik SSO integration
- Traefik IngressRoute already supports Authentik middleware
- Map Authentik identity to a stored Gitea token
- Consistent with other cluster apps
1.4 — Gitea Aggregation Layer
internal/gitea/client.go — core aggregation functions the raw API doesn't provide:
| Function | Purpose |
|---|---|
ListAllIssues(orgs []string) |
Fan-out across repos with errgroup, merge + sort by updated time |
ListAllPullRequests(orgs []string) |
Same pattern for PRs, includes review status |
GetTriageQueue() |
Unassigned issues + PRs awaiting review, sorted by priority labels |
ListOrgsAndRepos() |
Enumerate all orgs the user belongs to, list repos per org |
ApplyLabel() |
Thin wrapper for labeling issues |
SubmitReview() |
Approve / request changes / comment on a PR |
CreateIssue() |
Create an issue with labels in a specified repo |
Design decisions:
- Concurrent API calls across repos using
errgroupwith a semaphore (cap at 5-10) - In-memory cache with 30-second TTL using
sync.RWMutex - Cache keys:
orgs-repos,issues-{org},pulls-{org} - Invalidate cache on write operations
1.5 — HTTP Handlers
Using Go 1.22+ stdlib http.ServeMux (no external router needed):
| Route | Method | Purpose |
|---|---|---|
/ |
GET | Dashboard / triage queue |
/issues |
GET | All issues across orgs |
/pulls |
GET | All PRs across orgs |
/issues/{owner}/{repo}/{index} |
GET | Issue detail |
/pulls/{owner}/{repo}/{index} |
GET | PR detail + diff stats |
/issues |
POST | Create issue |
/issues/{owner}/{repo}/{index}/labels |
POST | Assign labels |
/pulls/{owner}/{repo}/{index}/review |
POST | Submit PR review |
/settings |
GET | Token configuration page |
/settings |
POST | Save token |
/health |
GET | K8s liveness/readiness probe |
Each handler checks the HX-Request header to decide whether to return a full
page or an HTMX HTML fragment.
Phase 2: Frontend (Mobile-First PWA)
2.1 — Layout
- Fixed bottom navigation bar (4 tabs): Dashboard, Issues, PRs, Settings
- Top bar with org/repo filter dropdown
- Pull-to-refresh via HTMX polling or manual trigger
- Dark mode via
prefers-color-schememedia query - iPhone safe areas via
env(safe-area-inset-bottom)
2.2 — Views
Dashboard / Triage (/)
- Card-based list of items needing attention
- Each card: repo name, issue/PR title, labels (colored badges), age
- Tap to expand inline via HTMX
hx-getloading a detail fragment
Issues List (/issues)
- Filter bar: org, repo, state (open/closed), label
- Infinite scroll via HTMX
hx-trigger="revealed"on a sentinel element - Each row: colored dot for state, title, repo badge, label pills, assignee avatar
PR List (/pulls)
- Same layout as issues plus: review status icon, merge status indicator
- Tap to view PR detail with diff stats
Issue Detail (/issues/{owner}/{repo}/{index})
- Title, body (rendered markdown via Gitea's API markdown endpoint)
- Comments thread
- Action buttons: Add label (dropdown), Assign, Comment, Close
- All actions via HTMX
hx-postwithhx-swap="outerHTML"
PR Detail (/pulls/{owner}/{repo}/{index})
- Same as issue detail plus: diff stat summary, file list, review form
- Review form: textarea + radio (approve/request changes/comment) + submit
- Mergeable status indicator
Create Issue (/issues/new)
- Form: searchable repo selector, title, body textarea, label multi-select
- Submit via HTMX, redirect to new issue detail
2.3 — HTMX Patterns
<!-- Infinite scroll -->
<div hx-get="/issues?page=1" hx-trigger="load" hx-swap="innerHTML">
Loading...
</div>
<!-- Filter without full reload -->
<select hx-get="/issues" hx-trigger="change" hx-target="#issue-list"
hx-include="[name='org']" name="org">
<option value="all">All orgs</option>
</select>
<!-- Quick label assignment -->
<form hx-post="/issues/org/repo/42/labels" hx-swap="outerHTML"
hx-target="closest .issue-card">
<select name="label_id">...</select>
<button type="submit">Apply</button>
</form>
2.4 — PWA Setup
Files in /static/:
manifest.json— app name "Gitea Mobile", displaystandalone, theme color, iconssw.js— service worker caching the app shell (layout, CSS, HTMX JS, icons)icon-192.png,icon-512.png— app icons- Apple-specific meta tags:
apple-mobile-web-app-capable,apple-mobile-web-app-status-bar-style,apple-touch-icon
2.5 — CSS Strategy
Mobile-first with progressive enhancement, ~5KB target:
/* Base: mobile (< 640px) */
:root {
--spacing: 0.5rem;
--radius: 8px;
}
.card { margin: var(--spacing); padding: var(--spacing); }
.bottom-nav { position: fixed; bottom: 0; }
/* Safe area for iPhone notch */
.bottom-nav { padding-bottom: env(safe-area-inset-bottom); }
/* Tablet (>= 640px): 2-column grid */
@media (min-width: 640px) { ... }
Phase 3: Containerization & Talos Deployment
3.1 — Dockerfile
Multi-stage build producing a ~15-20MB image:
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /gitea-mobile ./cmd/server
# Stage 2: Runtime
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /gitea-mobile /gitea-mobile
COPY static/ /static/
COPY internal/templates/ /templates/
EXPOSE 8080
ENTRYPOINT ["/gitea-mobile"]
3.2 — Container Registry
Push to Gitea container registry: gitea.leeworks.dev/0xwheatyz/gitea-mobile
Tag with timestamp + commit SHA. Flux image automation picks up new tags
automatically via $imagepolicy annotations.
3.3 — Kubernetes Manifests
Create in the Talos repo at apps/gitea-mobile/:
| File | Purpose |
|---|---|
namespace.yaml |
gitea-mobile namespace |
deployment.yaml |
Single-container, health probes, resource limits |
service.yaml |
ClusterIP on port 8080 |
secret.yaml |
SESSION_SECRET (migrate to sealed-secrets later) |
ingressroute.yaml |
Traefik route at gitea-mobile.testing.leeworks.dev |
kustomization.yaml |
Kustomize manifest listing all resources |
Deployment details:
- Resources: requests
64Mi/50m, limits256Mi/500m - Probes: liveness + readiness on
GET /health:8080 - Strategy:
Recreate(single replica) - Env:
GITEA_URL=http://gitea.gitea.svc.cluster.local:3000,SESSION_SECRETfrom secret - IngressRoute: Authentik middleware,
security-headers, TLS viawildcard-testing-leeworks-dev
3.4 — CI (Optional)
Gitea Actions workflow (.gitea/workflows/build.yaml):
- On push to
main: rungo test ./... - Build Docker image, tag with timestamp + commit SHA
- Push to
gitea.leeworks.devregistry - Flux picks up new image tag automatically
Implementation Order
| Step | What | Phase |
|---|---|---|
| 1 | Scaffold Go project + nix shell | P1 |
| 2 | Gitea SDK client + org/repo enumeration | P1 |
| 3 | Aggregation layer (issues, PRs, triage) | P1 |
| 4 | Auth (token-in-cookie) | P1 |
| 5 | HTTP handlers | P1 |
| 6 | Templates + HTMX + base layout | P2 |
| 7 | All views (dashboard, lists, detail, create) | P2 |
| 8 | Mobile CSS + dark mode | P2 |
| 9 | PWA manifest + service worker | P2 |
| 10 | Dockerfile + local test | P3 |
| 11 | K8s manifests in Talos repo | P3 |
| 12 | Push image + deploy + verify on phone | P3 |
Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Gitea API rate limiting with many repos | In-memory cache (30s TTL), concurrent fetches with semaphore, lazy pagination |
| iPhone Safari PWA quirks | Test early with iOS meta tags, standalone display, safe-area-inset handling |
| Token security in cookies | HMAC-signed cookies, HttpOnly + Secure + SameSite=Strict |
| Secrets in Git | Plaintext for v1, migrate to sealed-secrets per Talos roadmap |
| No hot-reload in dev | air (Go live-reload) in the nix dev shell |