Compare commits

..

19 Commits

Author SHA1 Message Date
agent-company added0778e feat: implement CloseIssue and PostComment methods in gitea client
Add CloseIssue (PATCH state=closed) and PostComment (POST comment body)
methods to the Gitea client with cache invalidation. Add corresponding
handler routes POST /issues/{owner}/{repo}/{index}/close and
POST /issues/{owner}/{repo}/{index}/comment with HTMX support.
Include unit tests for both client methods.

Closes leeworks-agents/gitea-mobile#36

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:06:00 +00:00
AI-Manager 919a91d6aa Merge pull request 'feat: implement issue and PR detail handlers' (#27) from feature/issue-pr-detail-handlers into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 11:04:40 +00:00
AI-Manager 3c9a947017 Merge pull request 'fix: replace $GITHUB_OUTPUT with inline env vars in CI' (#26) from fix/remove-github-output into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 11:04:28 +00:00
agent-company 04e1f21405 feat: implement issue and PR detail handlers with routes
Add GET /issues/{owner}/{repo}/{index} and GET /pulls/{owner}/{repo}/{index}
routes that fetch individual issues/PRs from the Gitea API and render them
using the existing issue_detail.html and pull_detail.html templates.

New client methods:
- GetIssue: fetch a single issue by owner/repo/index
- GetPull: fetch a single pull request by owner/repo/index
- GetIssueComments: fetch comments for an issue
- GetRepoLabels: fetch available labels for a repository

Both handlers support HTMX fragment responses and full-page rendering,
consistent with the existing handler pattern.

Closes leeworks-agents/gitea-mobile#24

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:05:17 +00:00
agent-company cc90857cf5 fix: replace $GITHUB_OUTPUT with inline env vars in CI workflow
Collapse Set image tag, Build Docker image, and Push image steps into
a single step that computes TAG inline, eliminating the dependency on
$GITHUB_OUTPUT which is not reliably available in Gitea Actions runners.
Also moves registry login before the build+push step for correct ordering.

Closes leeworks-agents/gitea-mobile#25

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:03:14 +00:00
AI-Manager 2367c19e42 Merge pull request 'fix: replace github.sha with gitea.sha in CI workflow' (#23) from fix/gitea-sha-ci-workflow into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 08:04:25 +00:00
agent-company abd879ab66 fix: replace github.sha with gitea.sha in CI workflow
The Gitea Actions workflow used ${{ github.sha }} which is GitHub Actions
syntax. In Gitea Actions the correct context variable is ${{ gitea.sha }}.
This caused the image tag SHA component to be empty.

Closes leeworks-agents/gitea-mobile#20

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:03:55 +00:00
AI-Manager eb1252f902 Merge pull request 'fix: vendor htmx.min.js locally instead of loading from CDN' (#19) from fix/vendor-htmx-locally into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 08:02:27 +00:00
agent-company 37ddfb128b fix: vendor htmx.min.js locally instead of loading from CDN
Download htmx.org v1.9.10 into static/htmx.min.js and update all
references (layout.html, handlers.go fallback page, sw.js precache
list) to use the local copy. This enables the PWA to work fully
offline since the service worker can now cache htmx from the same
origin.

Bump service worker cache version to v2 so existing installations
pick up the new asset list.

Closes leeworks-agents/gitea-mobile#17

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:03:46 +00:00
AI-Manager cf841ac5d9 Merge pull request 'feat: implement mobile-first HTMX templates and CSS' (#15) from feature/templates-css into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 05:05:32 +00:00
AI-Manager 43d621e688 Merge pull request 'feat: add PWA manifest and service worker' (#14) from feature/pwa into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 05:05:20 +00:00
AI-Manager 4a25f5fac4 Merge pull request 'feat: add Dockerfile and CI workflow' (#13) from feature/dockerfile-ci into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-26 05:05:14 +00:00
AI-Manager 180fd9b65c Merge pull request 'feat: add HTTP handlers and health endpoint' (#12) from feature/http-handlers into master 2026-03-26 05:05:01 +00:00
AI-Manager f464e11b00 Merge pull request 'feat: implement Gitea aggregation layer with concurrent fetching' (#11) from feature/gitea-aggregation into master 2026-03-26 05:04:46 +00:00
AI-Manager 24b44debf0 Merge pull request 'feat: add env-based configuration and token-in-cookie auth' (#10) from feature/config-auth into master 2026-03-26 05:04:23 +00:00
agent-company 81496c775e feat: implement mobile-first HTMX templates and CSS
Build all frontend views using Go html/template with HTMX interactions
and mobile-first CSS with dark mode and iPhone safe areas.

Templates:
- layout.html: base layout with fixed bottom nav (Dashboard, Issues, PRs, Settings)
- dashboard.html: card-based triage view with priority indicators
- issues.html: issue list with filter bar (org, state) and infinite scroll
- pulls.html: PR list with diff stats and merge status
- issue_detail.html: issue detail with comments, label assignment
- pull_detail.html: PR detail with diff stats and review form
- create_issue.html: issue creation form with searchable repo selector

CSS (static/style.css):
- Mobile-first dark mode with CSS variables
- iPhone safe areas via env(safe-area-inset-bottom)
- Responsive: 2-column grid at 640px+ breakpoint
- Light mode support via prefers-color-scheme
- Card, label, badge, form, button component styles
- HTMX loading indicator and spinner animation
- Reduced motion support

HTMX patterns:
- hx-get with hx-target for SPA-like navigation
- hx-trigger="revealed" for infinite scroll
- hx-post with hx-swap for inline form actions
- Filter bar with hx-trigger="change" and hx-include

Closes leeworks-agents/gitea-mobile#5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 04:15:06 +00:00
agent-company 712dc5632c feat: add PWA manifest and service worker
Add Progressive Web App support for installable mobile experience
on iOS Safari and Android Chrome.

- static/manifest.json: app name, standalone display, dark theme,
  icon references for 192px and 512px
- static/sw.js: service worker with cache-first strategy for static
  assets, network-first for HTML/HTMX, offline fallback to cache
- static/icon-192.png, static/icon-512.png: placeholder app icons
- Apple meta tags: apple-mobile-web-app-capable, status bar style,
  apple-touch-icon for iOS Add to Home Screen
- Service worker registration in base layout template

Closes leeworks-agents/gitea-mobile#6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 04:12:20 +00:00
agent-company 86173b61eb feat: add Dockerfile and CI workflow
Add multi-stage Dockerfile producing a minimal distroless image and
Gitea Actions CI workflow for automated testing and image publishing.

- Dockerfile: multi-stage build (golang:1.22-alpine -> distroless/static)
  with stripped binary (~15-20MB image), runs as nonroot user
- .dockerignore: excludes .git, docs, nix files from build context
- .gitea/workflows/build.yaml: CI pipeline that runs tests, builds
  Docker image, and pushes to Gitea registry with timestamp+SHA tags
  for Flux image automation

Closes leeworks-agents/gitea-mobile#7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 04:10:59 +00:00
agent-company 17ca1f6e6c feat: add HTTP handlers and health endpoint
Implement all HTTP handlers using Go 1.22+ stdlib ServeMux with
HTMX fragment vs full-page response detection.

- internal/handlers/handlers.go: all route handlers
  - GET /health returns 200 for K8s probes
  - GET / dashboard with triage queue from aggregation layer
  - GET /issues lists all issues across orgs
  - GET /pulls lists all PRs across orgs
  - POST /issues creates issue via aggregation layer
  - POST /issues/{owner}/{repo}/{index}/labels assigns labels
  - POST /pulls/{owner}/{repo}/{index}/review submits PR review
  - HX-Request header detection for HTMX fragment vs full page
  - Mobile-first dark theme base layout with bottom navigation
- cmd/server/main.go: refactored to use centralized route registration
- internal/handlers/handlers_test.go: unit tests for health, dashboard,
  HTMX detection, input validation

Closes leeworks-agents/gitea-mobile#4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 04:10:03 +00:00
23 changed files with 1966 additions and 27 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.gitignore
*.md
flake.nix
flake.lock
.envrc
.direnv
.claude
+41
View File
@@ -0,0 +1,41 @@
name: Build and Push
on:
push:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run tests
run: go test ./...
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Login to Gitea registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login gitea.leeworks.dev \
-u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build and push Docker image
run: |
TIMESTAMP=$(date +%Y%m%d%H%M%S)
SHA=$(echo ${{ gitea.sha }} | cut -c1-7)
TAG="${TIMESTAMP}-${SHA}"
docker build -t gitea.leeworks.dev/0xwheatyz/gitea-mobile:${TAG} .
docker tag gitea.leeworks.dev/0xwheatyz/gitea-mobile:${TAG} \
gitea.leeworks.dev/0xwheatyz/gitea-mobile:latest
docker push gitea.leeworks.dev/0xwheatyz/gitea-mobile:${TAG}
docker push gitea.leeworks.dev/0xwheatyz/gitea-mobile:latest
+16
View File
@@ -0,0 +1,16 @@
# 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 GOOS=linux go build -ldflags="-s -w" -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
USER nonroot:nonroot
ENTRYPOINT ["/gitea-mobile"]
+6 -27
View File
@@ -1,14 +1,13 @@
package main package main
import ( import (
"fmt"
"log" "log"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"strings"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config" "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/handlers" "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/handlers"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
) )
@@ -24,33 +23,13 @@ func main() {
log.Fatalf("configuration error: %v", err) log.Fatalf("configuration error: %v", err)
} }
// Determine if cookies should be marked Secure (disable for local dev). // Create Gitea API client.
secureCookies := !strings.HasPrefix(cfg.ListenAddr, "localhost") && client := giteaclient.NewClient(cfg.GiteaURL)
!strings.HasPrefix(cfg.ListenAddr, "127.0.0.1")
// Create handler with all routes.
mux := http.NewServeMux() mux := http.NewServeMux()
h := handlers.NewHandler(cfg, client)
// Health endpoint (no auth required). h.RegisterRoutes(mux)
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
// Settings handler.
settingsHandler := &handlers.SettingsHandler{
SessionSecret: cfg.SessionSecret,
SecureCookies: secureCookies,
}
mux.HandleFunc("/settings", settingsHandler.ServeHTTP)
// Static file server.
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
// Placeholder dashboard (will be replaced in issue #4/#5).
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintln(w, "<h1>Gitea Mobile</h1><p>Dashboard coming soon.</p>")
})
// Apply middleware chain: logging -> auth. // Apply middleware chain: logging -> auth.
var handler http.Handler = mux var handler http.Handler = mux
+142
View File
@@ -530,6 +530,103 @@ func (c *Client) GetTriageQueue(ctx context.Context, token string, orgs []string
return queue, nil return queue, nil
} }
// Comment represents a comment on an issue or pull request.
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
User string `json:"-"` // populated from nested object
CreatedAt string `json:"-"` // formatted after fetch
RawUser struct {
Login string `json:"login"`
} `json:"user"`
RawCreatedAt time.Time `json:"created_at"`
}
// Label represents a Gitea label (used for available labels list).
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
// GetIssue fetches a single issue by owner, repo, and index.
func (c *Client) GetIssue(ctx context.Context, token, owner, repo string, index int64) (*Issue, error) {
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching issue: %w", err)
}
defer resp.Body.Close()
var issue Issue
if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
return nil, fmt.Errorf("decoding issue: %w", err)
}
issue.RepoOwner = owner
issue.RepoName = repo
return &issue, nil
}
// GetPull fetches a single pull request by owner, repo, and index.
func (c *Client) GetPull(ctx context.Context, token, owner, repo string, index int64) (*PullRequest, error) {
path := fmt.Sprintf("/repos/%s/%s/pulls/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching pull request: %w", err)
}
defer resp.Body.Close()
var pr PullRequest
if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil {
return nil, fmt.Errorf("decoding pull request: %w", err)
}
pr.RepoOwner = owner
pr.RepoName = repo
return &pr, nil
}
// GetIssueComments fetches comments for an issue or pull request.
func (c *Client) GetIssueComments(ctx context.Context, token, owner, repo string, index int64) ([]Comment, error) {
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments?limit=50", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching comments: %w", err)
}
defer resp.Body.Close()
var comments []Comment
if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil {
return nil, fmt.Errorf("decoding comments: %w", err)
}
// Populate convenience fields.
for i := range comments {
comments[i].User = comments[i].RawUser.Login
comments[i].CreatedAt = comments[i].RawCreatedAt.Format("Jan 2, 2006 15:04")
}
return comments, nil
}
// GetRepoLabels fetches all labels for a repository.
func (c *Client) GetRepoLabels(ctx context.Context, token, owner, repo string) ([]Label, error) {
path := fmt.Sprintf("/repos/%s/%s/labels?limit=50", owner, repo)
resp, err := c.doRequest(ctx, token, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("fetching labels: %w", err)
}
defer resp.Body.Close()
var labels []Label
if err := json.NewDecoder(resp.Body).Decode(&labels); err != nil {
return nil, fmt.Errorf("decoding labels: %w", err)
}
return labels, nil
}
// CreateIssue creates a new issue in the specified repository. // CreateIssue creates a new issue in the specified repository.
func (c *Client) CreateIssue(ctx context.Context, token, owner, repo, title, body string, labels []int64) (*Issue, error) { func (c *Client) CreateIssue(ctx context.Context, token, owner, repo, title, body string, labels []int64) (*Issue, error) {
payload := map[string]interface{}{ payload := map[string]interface{}{
@@ -609,6 +706,51 @@ func (c *Client) SubmitReview(ctx context.Context, token, owner, repo string, in
return nil return nil
} }
// CloseIssue closes an issue by setting its state to "closed".
func (c *Client) CloseIssue(ctx context.Context, token, owner, repo string, index int64) error {
payload, err := json.Marshal(map[string]string{"state": "closed"})
if err != nil {
return fmt.Errorf("marshaling close request: %w", err)
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPatch, path, strings.NewReader(string(payload)))
if err != nil {
return fmt.Errorf("closing issue: %w", err)
}
resp.Body.Close()
c.InvalidateAll()
return nil
}
// PostComment creates a comment on an issue and returns the created Comment.
func (c *Client) PostComment(ctx context.Context, token, owner, repo string, index int64, body string) (*Comment, error) {
payload, err := json.Marshal(map[string]string{"body": body})
if err != nil {
return nil, fmt.Errorf("marshaling comment: %w", err)
}
path := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, index)
resp, err := c.doRequest(ctx, token, http.MethodPost, path, strings.NewReader(string(payload)))
if err != nil {
return nil, fmt.Errorf("posting comment: %w", err)
}
defer resp.Body.Close()
var comment Comment
if err := json.NewDecoder(resp.Body).Decode(&comment); err != nil {
return nil, fmt.Errorf("decoding comment: %w", err)
}
// Populate convenience fields.
comment.User = comment.RawUser.Login
comment.CreatedAt = comment.RawCreatedAt.Format("Jan 2, 2006 15:04")
c.InvalidateAll()
return &comment, nil
}
// priorityScore returns a numeric score for sorting (lower = higher priority). // priorityScore returns a numeric score for sorting (lower = higher priority).
func priorityScore(labels []string) int { func priorityScore(labels []string) int {
for _, l := range labels { for _, l := range labels {
+91
View File
@@ -201,6 +201,97 @@ func TestGetTriageQueue_Sorting(t *testing.T) {
} }
} }
func TestCloseIssue(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
t.Errorf("expected PATCH, got %s", r.Method)
}
if r.URL.Path != "/api/v1/repos/owner1/repo1/issues/42" {
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["state"] != "closed" {
t.Errorf("expected state=closed, got %q", body["state"])
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"state": "closed"})
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
err := c.CloseIssue(context.Background(), "test-token", "owner1", "repo1", 42)
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 CloseIssue")
}
}
func TestPostComment(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/comments" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
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["body"] != "test comment" {
t.Errorf("expected body='test comment', got %q", body["body"])
}
comment := map[string]interface{}{
"id": 1,
"body": body["body"],
"user": map[string]string{"login": "testuser"},
"created_at": "2026-03-26T12:00:00Z",
}
json.NewEncoder(w).Encode(comment)
}))
defer server.Close()
c := NewClient(server.URL)
c.setCache("issues-org1", "should-be-invalidated")
comment, err := c.PostComment(context.Background(), "test-token", "owner1", "repo1", 42, "test comment")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if comment.Body != "test comment" {
t.Errorf("comment.Body = %q, want %q", comment.Body, "test comment")
}
if comment.User != "testuser" {
t.Errorf("comment.User = %q, want %q", comment.User, "testuser")
}
if comment.ID != 1 {
t.Errorf("comment.ID = %d, want 1", comment.ID)
}
// Verify cache was invalidated.
_, ok := c.getFromCache("issues-org1")
if ok {
t.Error("expected cache to be invalidated after PostComment")
}
}
// sortTriageQueue is a test helper applying the same sort as GetTriageQueue. // sortTriageQueue is a test helper applying the same sort as GetTriageQueue.
func sortTriageQueue(queue []TriageItem) { func sortTriageQueue(queue []TriageItem) {
for i := 0; i < len(queue); i++ { for i := 0; i < len(queue); i++ {
+640
View File
@@ -0,0 +1,640 @@
// Package handlers implements HTTP handlers for the Gitea Mobile application.
package handlers
import (
"fmt"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
)
// Handler holds shared dependencies for all HTTP handlers.
type Handler struct {
Config *config.Config
Client *giteaclient.Client
}
// NewHandler creates a new Handler with the given config and client.
func NewHandler(cfg *config.Config, client *giteaclient.Client) *Handler {
return &Handler{
Config: cfg,
Client: client,
}
}
// RegisterRoutes registers all HTTP routes on the given ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Health endpoint.
mux.HandleFunc("GET /health", h.Health)
// Dashboard / triage.
mux.HandleFunc("GET /", h.Dashboard)
// Issues.
mux.HandleFunc("GET /issues", h.ListIssues)
mux.HandleFunc("POST /issues", h.CreateIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/close", h.CloseIssue)
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/comment", h.AddComment)
// Issue detail.
mux.HandleFunc("GET /issues/{owner}/{repo}/{index}", h.IssueDetail)
// Pull requests.
mux.HandleFunc("GET /pulls", h.ListPulls)
mux.HandleFunc("GET /pulls/{owner}/{repo}/{index}", h.PullDetail)
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview)
// Settings (handled separately for auth bypass).
settingsHandler := &SettingsHandler{
SessionSecret: h.Config.SessionSecret,
SecureCookies: true,
}
mux.HandleFunc("/settings", settingsHandler.ServeHTTP)
// Static files.
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
}
// isHTMX returns true if the request is an HTMX partial request.
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// getToken extracts the user's Gitea API token from request context.
func getToken(r *http.Request) string {
return middleware.TokenFromContext(r.Context())
}
// getUserOrgs returns the list of org names the user belongs to.
func (h *Handler) getUserOrgs(r *http.Request) []string {
token := getToken(r)
if token == "" {
return nil
}
orgs, err := h.Client.ListOrgs(r.Context(), token)
if err != nil {
slog.Error("failed to list orgs", "error", err)
return nil
}
var names []string
for _, org := range orgs {
names = append(names, org.Name)
}
return names
}
// Health handles GET /health for Kubernetes probes.
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
}
// basePage is the full HTML wrapper used for non-HTMX requests.
var basePage = template.Must(template.New("base").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">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#161b22">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<title>{{.Title}} — Gitea Mobile</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="content" id="main-content">
{{.Content}}
</div>
<nav class="bottom-nav">
<a href="/" {{if eq .ActiveTab "dashboard"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/></svg>
Dashboard
</a>
<a href="/issues" {{if eq .ActiveTab "issues"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Issues
</a>
<a href="/pulls" {{if eq .ActiveTab "pulls"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 009 9"/></svg>
PRs
</a>
<a href="/settings" {{if eq .ActiveTab "settings"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings
</a>
</nav>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
console.log('SW registration failed:', err);
});
}
</script>
</body>
</html>`))
type pageData struct {
Title string
ActiveTab string
Content template.HTML
}
// renderPage renders either a full page (for regular requests) or just the
// content fragment (for HTMX requests).
func renderPage(w http.ResponseWriter, r *http.Request, title, activeTab string, content string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if isHTMX(r) {
fmt.Fprint(w, content)
return
}
data := pageData{
Title: title,
ActiveTab: activeTab,
Content: template.HTML(content),
}
if err := basePage.Execute(w, data); err != nil {
slog.Error("template render error", "error", err)
}
}
// 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)
return
}
token := getToken(r)
orgs := h.getUserOrgs(r)
if len(orgs) == 0 {
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">No organizations found. Check your token permissions.</p>`)
return
}
queue, err := h.Client.GetTriageQueue(r.Context(), token, orgs)
if err != nil {
slog.Error("failed to get triage queue", "error", err)
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">Error loading triage queue.</p>`)
return
}
if len(queue) == 0 {
renderPage(w, r, "Dashboard", "dashboard",
`<h1>Dashboard</h1><p class="empty">No items need attention. Nice work!</p>`)
return
}
content := `<h1>Dashboard</h1>`
for _, item := range queue {
typeBadge := `<span class="type-badge type-issue">issue</span>`
if item.Type == "pull" {
typeBadge = `<span class="type-badge type-pull">PR</span>`
}
labels := ""
for _, l := range item.Labels {
color := "#8b949e"
switch l {
case "P1":
color = "#f85149"
case "P2":
color = "#d29922"
case "P3":
color = "#58a6ff"
}
labels += fmt.Sprintf(`<span class="label" style="color:%s;border:1px solid %s">%s</span>`, color, color, template.HTMLEscapeString(l))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s %s</div>
<div class="card-meta">%s/%s #%d %s</div>
</div>`, typeBadge, template.HTMLEscapeString(item.Title),
template.HTMLEscapeString(item.RepoOwner),
template.HTMLEscapeString(item.RepoName),
item.Number, labels)
}
renderPage(w, r, "Dashboard", "dashboard", content)
}
// ListIssues handles GET /issues.
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
orgs := h.getUserOrgs(r)
if len(orgs) == 0 {
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">No organizations found.</p>`)
return
}
issues, err := h.Client.ListAllIssues(r.Context(), token, orgs)
if err != nil {
slog.Error("failed to list issues", "error", err)
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">Error loading issues.</p>`)
return
}
if len(issues) == 0 {
renderPage(w, r, "Issues", "issues",
`<h1>Issues</h1><p class="empty">No open issues found.</p>`)
return
}
content := `<h1>Issues</h1>`
for _, issue := range issues {
labels := ""
for _, l := range issue.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
assignee := ""
if issue.Assignee != nil {
assignee = fmt.Sprintf(` &middot; %s`, template.HTMLEscapeString(issue.Assignee.Login))
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title">%s</div>
<div class="card-meta">%s/%s #%d %s%s</div>
</div>`, template.HTMLEscapeString(issue.Title),
template.HTMLEscapeString(issue.RepoOwner),
template.HTMLEscapeString(issue.RepoName),
issue.Number, labels, assignee)
}
renderPage(w, r, "Issues", "issues", content)
}
// ListPulls handles GET /pulls.
func (h *Handler) ListPulls(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
orgs := h.getUserOrgs(r)
if len(orgs) == 0 {
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">No organizations found.</p>`)
return
}
prs, err := h.Client.ListAllPullRequests(r.Context(), token, orgs)
if err != nil {
slog.Error("failed to list pull requests", "error", err)
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">Error loading pull requests.</p>`)
return
}
if len(prs) == 0 {
renderPage(w, r, "Pull Requests", "pulls",
`<h1>Pull Requests</h1><p class="empty">No open pull requests found.</p>`)
return
}
content := `<h1>Pull Requests</h1>`
for _, pr := range prs {
labels := ""
for _, l := range pr.Labels {
labels += fmt.Sprintf(`<span class="label" style="color:#%s;border:1px solid #%s">%s</span>`,
l.Color, l.Color, template.HTMLEscapeString(l.Name))
}
stats := fmt.Sprintf(`<span style="color:#3fb950">+%d</span> <span style="color:#f85149">-%d</span>`, pr.Additions, pr.Deletions)
mergeStatus := ""
if pr.Mergeable {
mergeStatus = `<span style="color:#3fb950;font-size:0.7rem;">mergeable</span>`
}
content += fmt.Sprintf(`<div class="card">
<div class="card-title"><span class="type-badge type-pull">PR</span> %s</div>
<div class="card-meta">%s/%s #%d %s %s %s</div>
</div>`, template.HTMLEscapeString(pr.Title),
template.HTMLEscapeString(pr.RepoOwner),
template.HTMLEscapeString(pr.RepoName),
pr.Number, labels, stats, mergeStatus)
}
renderPage(w, r, "Pull Requests", "pulls", content)
}
// IssueDetail handles GET /issues/{owner}/{repo}/{index}.
func (h *Handler) IssueDetail(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
issue, err := h.Client.GetIssue(r.Context(), token, owner, repo, index)
if err != nil {
slog.Error("failed to get issue", "error", err, "owner", owner, "repo", repo, "index", index)
renderPage(w, r, "Issue Not Found", "issues",
`<h1>Issue Not Found</h1><p class="empty">Could not load the requested issue.</p>`)
return
}
comments, err := h.Client.GetIssueComments(r.Context(), token, owner, repo, index)
if err != nil {
slog.Error("failed to get comments", "error", err)
comments = nil // non-fatal, render without comments
}
labels, err := h.Client.GetRepoLabels(r.Context(), token, owner, repo)
if err != nil {
slog.Error("failed to get repo labels", "error", err)
labels = nil
}
// Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/issue_detail.html")
if err != nil {
slog.Error("failed to parse issue_detail template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
type templateData struct {
Issue *giteaclient.Issue
Comments []giteaclient.Comment
AvailableLabels []giteaclient.Label
}
data := templateData{
Issue: issue,
Comments: comments,
AvailableLabels: labels,
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute issue_detail template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
renderPage(w, r, fmt.Sprintf("Issue #%d", index), "issues", buf.String())
}
// PullDetail handles GET /pulls/{owner}/{repo}/{index}.
func (h *Handler) PullDetail(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid PR index", http.StatusBadRequest)
return
}
pr, err := h.Client.GetPull(r.Context(), token, owner, repo, index)
if err != nil {
slog.Error("failed to get pull request", "error", err, "owner", owner, "repo", repo, "index", index)
renderPage(w, r, "PR Not Found", "pulls",
`<h1>Pull Request Not Found</h1><p class="empty">Could not load the requested pull request.</p>`)
return
}
// Build the content HTML using the template.
tmpl, err := template.ParseFiles("internal/templates/pull_detail.html")
if err != nil {
slog.Error("failed to parse pull_detail template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
type templateData struct {
Pull *giteaclient.PullRequest
}
data := templateData{
Pull: pr,
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute pull_detail template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
renderPage(w, r, fmt.Sprintf("PR #%d", index), "pulls", buf.String())
}
// CreateIssue handles POST /issues.
func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
owner := r.FormValue("owner")
repo := r.FormValue("repo")
title := r.FormValue("title")
body := r.FormValue("body")
if owner == "" || repo == "" || title == "" {
http.Error(w, "owner, repo, and title are required", http.StatusBadRequest)
return
}
issue, err := h.Client.CreateIssue(r.Context(), token, owner, repo, title, body, nil)
if err != nil {
slog.Error("failed to create issue", "error", err)
http.Error(w, "failed to create issue", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("HX-Redirect", fmt.Sprintf("/issues/%s/%s/%d", owner, repo, issue.Number))
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, issue.Number), http.StatusSeeOther)
}
// ApplyLabels handles POST /issues/{owner}/{repo}/{index}/labels.
func (h *Handler) ApplyLabels(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var labelIDs []int64
for _, idStr := range r.Form["label_id"] {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
continue
}
labelIDs = append(labelIDs, id)
}
if len(labelIDs) == 0 {
http.Error(w, "no labels specified", http.StatusBadRequest)
return
}
if err := h.Client.ApplyLabel(r.Context(), token, owner, repo, index, labelIDs); err != nil {
slog.Error("failed to apply labels", "error", err, "owner", owner, "repo", repo, "index", index)
http.Error(w, "failed to apply labels", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `<span style="color:#3fb950">Labels applied</span>`)
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// CloseIssue handles POST /issues/{owner}/{repo}/{index}/close.
func (h *Handler) CloseIssue(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := h.Client.CloseIssue(r.Context(), token, owner, repo, index); err != nil {
slog.Error("failed to close issue", "error", err, "owner", owner, "repo", repo, "index", index)
http.Error(w, "failed to close issue", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("HX-Redirect", fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index))
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// AddComment handles POST /issues/{owner}/{repo}/{index}/comment.
func (h *Handler) AddComment(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid issue index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
body := r.FormValue("body")
if body == "" {
http.Error(w, "comment body is required", http.StatusBadRequest)
return
}
comment, err := h.Client.PostComment(r.Context(), token, owner, repo, index, body)
if err != nil {
slog.Error("failed to post comment", "error", err, "owner", owner, "repo", repo, "index", index)
http.Error(w, "failed to post comment", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="card comment">
<div class="card-meta">%s &middot; %s</div>
<div class="card-body">%s</div>
</div>`, template.HTMLEscapeString(comment.User), template.HTMLEscapeString(comment.CreatedAt), template.HTMLEscapeString(comment.Body))
return
}
http.Redirect(w, r, fmt.Sprintf("/issues/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
// SubmitReview handles POST /pulls/{owner}/{repo}/{index}/review.
func (h *Handler) SubmitReview(w http.ResponseWriter, r *http.Request) {
token := getToken(r)
owner := r.PathValue("owner")
repo := r.PathValue("repo")
indexStr := r.PathValue("index")
index, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil {
http.Error(w, "invalid PR index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reviewType := r.FormValue("event") // APPROVED, REQUEST_CHANGES, COMMENT
body := r.FormValue("body")
if reviewType == "" {
http.Error(w, "review event type is required", http.StatusBadRequest)
return
}
if err := h.Client.SubmitReview(r.Context(), token, owner, repo, index, reviewType, body); err != nil {
slog.Error("failed to submit review", "error", err, "owner", owner, "repo", repo, "index", index)
http.Error(w, "failed to submit review", http.StatusInternalServerError)
return
}
if isHTMX(r) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `<span style="color:#3fb950">Review submitted</span>`)
return
}
http.Redirect(w, r, fmt.Sprintf("/pulls/%s/%s/%d", owner, repo, index), http.StatusSeeOther)
}
+149
View File
@@ -0,0 +1,149 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/config"
giteaclient "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/gitea"
)
func newTestHandler() *Handler {
cfg := &config.Config{
GiteaURL: "https://gitea.example.com",
SessionSecret: "test-secret-that-is-at-least-32-chars-long",
ListenAddr: ":8080",
}
client := giteaclient.NewClient(cfg.GiteaURL)
return NewHandler(cfg, client)
}
func TestHealth(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
h.Health(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if body := w.Body.String(); body != "ok\n" {
t.Errorf("body = %q, want %q", body, "ok\n")
}
}
func TestDashboard_NoToken(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.Dashboard(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
// Without a token in context, should show "No organizations found."
if body := w.Body.String(); body == "" {
t.Error("expected non-empty response body")
}
}
func TestDashboard_HTMX(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.Dashboard(w, req)
// HTMX request should not include full HTML page wrapper.
body := w.Body.String()
if body == "" {
t.Error("expected non-empty response body")
}
// Should NOT contain DOCTYPE for HTMX fragment.
if contains(body, "<!DOCTYPE") {
t.Error("HTMX response should not contain DOCTYPE")
}
}
func TestIsHTMX(t *testing.T) {
tests := []struct {
header string
want bool
}{
{"true", true},
{"false", false},
{"", false},
}
for _, tt := range tests {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tt.header != "" {
req.Header.Set("HX-Request", tt.header)
}
if got := isHTMX(req); got != tt.want {
t.Errorf("isHTMX(HX-Request=%q) = %v, want %v", tt.header, got, tt.want)
}
}
}
func TestCreateIssue_MissingFields(t *testing.T) {
h := newTestHandler()
req := httptest.NewRequest(http.MethodPost, "/issues", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.CreateIssue(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestApplyLabels_InvalidIndex(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /issues/{owner}/{repo}/{index}/labels", h.ApplyLabels)
req := httptest.NewRequest(http.MethodPost, "/issues/org/repo/abc/labels", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestSubmitReview_MissingEventType(t *testing.T) {
h := newTestHandler()
mux := http.NewServeMux()
mux.HandleFunc("POST /pulls/{owner}/{repo}/{index}/review", h.SubmitReview)
req := httptest.NewRequest(http.MethodPost, "/pulls/org/repo/1/review", nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
View File
+42
View File
@@ -0,0 +1,42 @@
{{define "content"}}
<h1>Create Issue</h1>
<form hx-post="/issues" hx-swap="innerHTML" hx-target="#main-content">
<div class="form-group">
<label for="repo-select">Repository</label>
<select id="repo-select" name="owner_repo" required>
<option value="">Select a repository...</option>
{{range $org, $repos := .Repos}}
<optgroup label="{{$org}}">
{{range $repos}}
<option value="{{.Owner.Login}}/{{.Name}}">{{.FullName}}</option>
{{end}}
</optgroup>
{{end}}
</select>
<input type="hidden" name="owner" id="owner-input">
<input type="hidden" name="repo" id="repo-input">
</div>
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" placeholder="Issue title" required>
</div>
<div class="form-group">
<label for="body">Description</label>
<textarea id="body" name="body" placeholder="Describe the issue..."></textarea>
</div>
<button type="submit" class="btn btn-primary">Create Issue</button>
</form>
<script>
// Split owner/repo from select into hidden fields.
document.getElementById('repo-select').addEventListener('change', function() {
var parts = this.value.split('/');
document.getElementById('owner-input').value = parts[0] || '';
document.getElementById('repo-input').value = parts[1] || '';
});
</script>
{{end}}
+26
View File
@@ -0,0 +1,26 @@
{{define "content"}}
<h1>Dashboard</h1>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Items}}
<p class="empty">No items need attention. Nice work!</p>
{{else}}
<div class="card-grid">
{{range .Items}}
<div class="card" hx-get="/{{if eq .Type "pull"}}pulls{{else}}issues{{end}}/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">
{{if eq .Type "pull"}}<span class="type-badge type-pull">PR</span>{{else}}<span class="type-badge type-issue">issue</span>{{end}}
{{.Title}}
</div>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label">{{.}}</span>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "content"}}
<h1>{{.Issue.Title}}</h1>
<div class="card">
<div class="card-meta">
<span class="state-open">{{.Issue.State}}</span>
<span>{{.Issue.RepoOwner}}/{{.Issue.RepoName}} #{{.Issue.Number}}</span>
{{range .Issue.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
</div>
{{if .Issue.Body}}
<div class="card-body">{{.Issue.Body}}</div>
{{end}}
</div>
{{if .Comments}}
<h2>Comments</h2>
{{range .Comments}}
<div class="comment">
<div class="comment-header">
<strong>{{.User}}</strong>
<span>{{.CreatedAt}}</span>
</div>
<div class="comment-body">{{.Body}}</div>
</div>
{{end}}
{{end}}
<div class="card" style="margin-top:1rem;">
<h2>Actions</h2>
<form hx-post="/issues/{{.Issue.RepoOwner}}/{{.Issue.RepoName}}/{{.Issue.Number}}/labels" hx-swap="outerHTML" style="margin-bottom:0.5rem;">
<div class="filter-bar" style="margin-bottom:0.5rem;">
<select name="label_id">
{{range .AvailableLabels}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
<button type="submit" class="btn btn-secondary" style="width:auto;padding:0.5rem 1rem;">Apply Label</button>
</div>
</form>
</div>
{{end}}
+44
View File
@@ -0,0 +1,44 @@
{{define "content"}}
<h1>Issues</h1>
<div class="filter-bar">
<select name="org" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='state']">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
<select name="state" hx-get="/issues" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true" hx-include="[name='org']">
<option value="open" {{if eq .SelectedState "open"}}selected{{end}}>Open</option>
<option value="closed" {{if eq .SelectedState "closed"}}selected{{end}}>Closed</option>
</select>
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Issues}}
<p class="empty">No issues found.</p>
{{else}}
<div id="issue-list">
{{range .Issues}}
<div class="card" hx-get="/issues/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">{{.Title}}</div>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
{{if .Assignee}}
<span>{{.Assignee.Login}}</span>
{{end}}
</div>
</div>
{{end}}
{{if .HasMore}}
<div class="scroll-sentinel" hx-get="/issues?page={{.NextPage}}&org={{.SelectedOrg}}&state={{.SelectedState}}" hx-trigger="revealed" hx-swap="outerHTML" hx-target="this">
<div class="spinner htmx-indicator"></div>
</div>
{{end}}
</div>
{{end}}
{{end}}
+45
View File
@@ -0,0 +1,45 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#161b22">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<title>{{.Title}} — Gitea Mobile</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
</head>
<body>
<div class="content" id="main-content">
{{template "content" .}}
</div>
<nav class="bottom-nav">
<a href="/" {{if eq .ActiveTab "dashboard"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/></svg>
Dashboard
</a>
<a href="/issues" {{if eq .ActiveTab "issues"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Issues
</a>
<a href="/pulls" {{if eq .ActiveTab "pulls"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 009 9"/></svg>
PRs
</a>
<a href="/settings" {{if eq .ActiveTab "settings"}}class="active"{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
Settings
</a>
</nav>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
console.log('SW registration failed:', err);
});
}
</script>
</body>
</html>{{end}}
+47
View File
@@ -0,0 +1,47 @@
{{define "content"}}
<h1>{{.Pull.Title}}</h1>
<div class="card">
<div class="card-meta">
<span class="type-badge type-pull">PR</span>
<span class="state-open">{{.Pull.State}}</span>
<span>{{.Pull.RepoOwner}}/{{.Pull.RepoName}} #{{.Pull.Number}}</span>
{{range .Pull.Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{end}}
</div>
<div class="card-meta" style="margin-top:0.5rem;">
<span class="diff-add">+{{.Pull.Additions}}</span>
<span class="diff-del">-{{.Pull.Deletions}}</span>
{{if .Pull.Mergeable}}<span style="color:var(--accent-green);">Mergeable</span>{{end}}
</div>
{{if .Pull.Body}}
<div class="card-body">{{.Pull.Body}}</div>
{{end}}
</div>
<div class="card" style="margin-top:1rem;">
<h2>Submit Review</h2>
<form hx-post="/pulls/{{.Pull.RepoOwner}}/{{.Pull.RepoName}}/{{.Pull.Number}}/review" hx-swap="outerHTML">
<div class="form-group">
<label for="review-body">Comment</label>
<textarea id="review-body" name="body" placeholder="Leave a review comment..."></textarea>
</div>
<div class="review-options">
<label class="review-option">
<input type="radio" name="event" value="COMMENT" checked>
<span>Comment</span>
</label>
<label class="review-option">
<input type="radio" name="event" value="APPROVED">
<span>Approve</span>
</label>
<label class="review-option">
<input type="radio" name="event" value="REQUEST_CHANGES">
<span>Request Changes</span>
</label>
</div>
<button type="submit" class="btn btn-primary">Submit Review</button>
</form>
</div>
{{end}}
+38
View File
@@ -0,0 +1,38 @@
{{define "content"}}
<h1>Pull Requests</h1>
<div class="filter-bar">
<select name="org" hx-get="/pulls" hx-trigger="change" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<option value="">All orgs</option>
{{range .Orgs}}
<option value="{{.}}" {{if eq . $.SelectedOrg}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
{{if .Error}}
<p class="empty">{{.Error}}</p>
{{else if not .Pulls}}
<p class="empty">No open pull requests found.</p>
{{else}}
<div id="pull-list">
{{range .Pulls}}
<div class="card" hx-get="/pulls/{{.RepoOwner}}/{{.RepoName}}/{{.Number}}" hx-target="#main-content" hx-swap="innerHTML" hx-push-url="true">
<div class="card-title">
<span class="type-badge type-pull">PR</span>
{{.Title}}
</div>
<div class="card-meta">
<span>{{.RepoOwner}}/{{.RepoName}} #{{.Number}}</span>
{{range .Labels}}
<span class="label" style="color:#{{.Color}};border:1px solid #{{.Color}}">{{.Name}}</span>
{{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}}
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
View File
+1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"name": "Gitea Mobile",
"short_name": "Gitea",
"description": "Mobile-first PWA for managing Gitea issues and pull requests",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#161b22",
"orientation": "portrait",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+477
View File
@@ -0,0 +1,477 @@
/* Gitea Mobile — Mobile-first CSS (~5KB target) */
/* Reset */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* CSS Variables */
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--border: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-link: #58a6ff;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-yellow: #d29922;
--accent-blue: #58a6ff;
--accent-purple: #bc8cff;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 0.75rem;
--spacing-lg: 1rem;
--radius: 8px;
--radius-sm: 6px;
--radius-pill: 10px;
--nav-height: 56px;
--font-sm: 0.75rem;
--font-base: 0.875rem;
--font-lg: 1rem;
--font-xl: 1.25rem;
}
/* Base */
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Content area */
.content {
padding: var(--spacing-lg);
padding-top: max(var(--spacing-lg), env(safe-area-inset-top));
max-width: 640px;
margin: 0 auto;
}
/* Typography */
h1 {
font-size: var(--font-xl);
font-weight: 700;
margin-bottom: var(--spacing-lg);
}
h2 {
font-size: var(--font-lg);
font-weight: 600;
margin-bottom: var(--spacing-md);
}
a {
color: var(--text-link);
text-decoration: none;
}
a:active {
opacity: 0.7;
}
/* Cards */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--spacing-md);
margin-bottom: var(--spacing-sm);
transition: background 0.15s ease;
}
.card:active {
background: var(--bg-tertiary);
}
.card-title {
font-weight: 600;
font-size: var(--font-base);
margin-bottom: var(--spacing-xs);
line-height: 1.3;
}
.card-meta {
font-size: var(--font-sm);
color: var(--text-secondary);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-xs);
}
.card-body {
font-size: var(--font-base);
color: var(--text-secondary);
margin-top: var(--spacing-sm);
line-height: 1.5;
}
/* Labels / badges */
.label {
display: inline-block;
font-size: 0.7rem;
padding: 1px 6px;
border-radius: var(--radius-pill);
font-weight: 500;
line-height: 1.5;
white-space: nowrap;
}
.type-badge {
font-size: 0.65rem;
text-transform: uppercase;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
}
.type-issue {
background: rgba(31, 111, 235, 0.13);
color: var(--accent-blue);
border: 1px solid rgba(31, 111, 235, 0.27);
}
.type-pull {
background: rgba(35, 134, 54, 0.13);
color: var(--accent-green);
border: 1px solid rgba(35, 134, 54, 0.27);
}
.state-open {
color: var(--accent-green);
}
.state-closed {
color: var(--accent-red);
}
.state-merged {
color: var(--accent-purple);
}
/* Priority labels */
.priority-p1 { color: var(--accent-red); border-color: var(--accent-red); }
.priority-p2 { color: var(--accent-yellow); border-color: var(--accent-yellow); }
.priority-p3 { color: var(--accent-blue); border-color: var(--accent-blue); }
/* Diff stats */
.diff-add { color: var(--accent-green); font-size: var(--font-sm); }
.diff-del { color: var(--accent-red); font-size: var(--font-sm); }
/* Bottom navigation */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
align-items: center;
height: var(--nav-height);
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
.bottom-nav a {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.7rem;
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 0;
min-width: 64px;
-webkit-tap-highlight-color: transparent;
}
.bottom-nav a.active {
color: var(--accent-blue);
}
.bottom-nav svg {
width: 22px;
height: 22px;
margin-bottom: 2px;
}
/* Filter bar */
.filter-bar {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: var(--spacing-xs);
}
.filter-bar::-webkit-scrollbar {
display: none;
}
.filter-bar select,
.filter-bar input {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-base);
min-width: 0;
flex-shrink: 0;
appearance: none;
-webkit-appearance: none;
}
.filter-bar select:focus,
.filter-bar input:focus {
outline: none;
border-color: var(--accent-blue);
}
/* Forms */
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-group label {
display: block;
font-size: var(--font-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-lg);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
}
.form-group textarea {
min-height: 120px;
resize: vertical;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-blue);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md) var(--spacing-lg);
font-size: var(--font-lg);
font-weight: 600;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s ease;
}
.btn-primary {
background: #238636;
color: #fff;
width: 100%;
}
.btn-primary:active {
background: #2ea043;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
width: 100%;
}
.btn-secondary:active {
background: var(--border);
}
.btn-danger {
background: #da363322;
color: var(--accent-red);
border: 1px solid #da363366;
width: 100%;
}
/* Review form */
.review-options {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-lg);
}
.review-option {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.review-option input[type="radio"] {
accent-color: var(--accent-blue);
}
/* Comments thread */
.comment {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--spacing-sm);
overflow: hidden;
}
.comment-header {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-tertiary);
font-size: var(--font-sm);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.comment-body {
padding: var(--spacing-md);
font-size: var(--font-base);
line-height: 1.6;
}
/* Avatar */
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
}
/* Empty state */
.empty {
text-align: center;
color: var(--text-secondary);
padding: 3rem var(--spacing-lg);
font-size: var(--font-base);
}
/* Messages */
.message {
padding: var(--spacing-md);
border-radius: var(--radius-sm);
margin-bottom: var(--spacing-lg);
font-size: var(--font-base);
}
.message.success {
background: #0d2818;
border: 1px solid #238636;
color: var(--accent-green);
}
.message.error {
background: #2d1117;
border: 1px solid #da3633;
color: var(--accent-red);
}
.message.info {
background: #0c1d2e;
border: 1px solid #1f6feb;
color: var(--accent-blue);
}
/* Loading indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent-blue);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Infinite scroll sentinel */
.scroll-sentinel {
height: 1px;
margin-bottom: var(--spacing-lg);
}
/* Tablet breakpoint: 2-column grid */
@media (min-width: 640px) {
.content {
max-width: 960px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
.card-grid .card {
margin-bottom: 0;
}
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* Dark mode is default; light mode override if needed */
@media (prefers-color-scheme: light) {
:root {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #eaeef2;
--border: #d0d7de;
--text-primary: #1f2328;
--text-secondary: #656d76;
--text-link: #0969da;
}
}
+86
View File
@@ -0,0 +1,86 @@
// Service Worker for Gitea Mobile PWA
// Caches the app shell for offline/fast loading.
const CACHE_NAME = 'gitea-mobile-v2';
const APP_SHELL = [
'/',
'/static/style.css',
'/static/manifest.json',
'/static/icon-192.png',
'/static/icon-512.png',
'/static/htmx.min.js'
];
// Install: cache app shell resources.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(APP_SHELL);
})
);
// Activate immediately.
self.skipWaiting();
});
// Activate: clean up old caches.
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
);
})
);
// Take control of all clients immediately.
self.clients.claim();
});
// Fetch: network-first for API/HTML, cache-first for static assets.
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache-first for static assets.
if (url.pathname.startsWith('/static/')) {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
// Cache the fetched response for next time.
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
return;
}
// Network-first for HTML/API requests.
if (event.request.headers.get('accept')?.includes('text/html') ||
event.request.headers.get('HX-Request')) {
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful GET responses.
if (event.request.method === 'GET' && response.ok) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Fallback to cache if network fails.
return caches.match(event.request);
})
);
return;
}
// Default: network only.
event.respondWith(fetch(event.request));
});