Compare commits

..

8 Commits

Author SHA1 Message Date
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
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 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
6 changed files with 1535 additions and 3 deletions
+211
View File
@@ -377,6 +377,217 @@ func sortTriageQueue(queue []TriageItem) {
}
}
// --- Issue #117: Tests for GetTriageQueue aggregation ---
func TestGetTriageQueue_Integration(t *testing.T) {
// Mock server that returns issues (some assigned, some not) and PRs.
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues") && r.Method == "GET":
// Return mix of assigned and unassigned issues.
issues := []map[string]interface{}{
{
"id": 1, "number": 1, "title": "Unassigned bug",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{{"id": 1, "name": "P1", "color": "ff0000"}},
"html_url": "http://example.com/org1/repo1/issues/1",
},
{
"id": 2, "number": 2, "title": "Assigned issue",
"state": "open",
"assignee": map[string]string{"login": "dev1", "avatar_url": ""},
"assignees": []map[string]string{{"login": "dev1", "avatar_url": ""}},
"labels": []interface{}{},
"html_url": "http://example.com/org1/repo1/issues/2",
},
{
"id": 3, "number": 3, "title": "Unassigned low priority",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{{"id": 2, "name": "P3", "color": "00ff00"}},
"html_url": "http://example.com/org1/repo1/issues/3",
},
}
json.NewEncoder(w).Encode(issues)
case strings.HasSuffix(r.URL.Path, "/pulls") && r.Method == "GET":
prs := []map[string]interface{}{
{
"id": 10, "number": 10, "title": "Open PR needs review",
"state": "open", "body": "please review",
"labels": []map[string]interface{}{{"id": 3, "name": "P2", "color": "ffff00"}},
"html_url": "http://example.com/org1/repo1/pulls/10",
"head": map[string]string{"label": "feature", "ref": "feature"},
"base": map[string]string{"label": "master", "ref": "master"},
},
}
json.NewEncoder(w).Encode(prs)
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
// Should include: 2 unassigned issues + 1 PR = 3 items.
// Assigned issue (#2) should be excluded.
if len(queue) != 3 {
t.Fatalf("expected 3 triage items, got %d", len(queue))
}
// Verify sorting: P1 > P2 > P3.
if queue[0].Title != "Unassigned bug" {
t.Errorf("queue[0] should be P1 'Unassigned bug', got %q", queue[0].Title)
}
if queue[1].Title != "Open PR needs review" {
t.Errorf("queue[1] should be P2 'Open PR needs review', got %q", queue[1].Title)
}
if queue[2].Title != "Unassigned low priority" {
t.Errorf("queue[2] should be P3 'Unassigned low priority', got %q", queue[2].Title)
}
// Verify types.
if queue[0].Type != "issue" {
t.Errorf("queue[0].Type = %q, want 'issue'", queue[0].Type)
}
if queue[1].Type != "pull" {
t.Errorf("queue[1].Type = %q, want 'pull'", queue[1].Type)
}
}
func TestGetTriageQueue_EmptyOrgs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{})
if err != nil {
t.Fatalf("GetTriageQueue with empty orgs: %v", err)
}
if len(queue) != 0 {
t.Errorf("expected empty queue for empty orgs, got %d items", len(queue))
}
}
func TestGetTriageQueue_AllAssigned(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues"):
// All issues are assigned.
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": 1, "number": 1, "title": "Assigned issue",
"state": "open",
"assignee": map[string]string{"login": "dev1"},
"assignees": []map[string]string{{"login": "dev1"}},
"labels": []interface{}{},
"html_url": "http://example.com/org1/repo1/issues/1",
},
})
case strings.HasSuffix(r.URL.Path, "/pulls"):
json.NewEncoder(w).Encode([]interface{}{}) // No PRs.
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
// Only PRs should appear (none here), all issues are assigned.
if len(queue) != 0 {
t.Errorf("expected 0 items (all assigned), got %d", len(queue))
}
}
func TestGetTriageQueue_LabelExtraction(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/user/orgs":
json.NewEncoder(w).Encode([]Org{{Name: "org1"}})
case strings.HasPrefix(r.URL.Path, "/api/v1/orgs/org1/repos"):
json.NewEncoder(w).Encode([]Repo{
{ID: 1, Name: "repo1", FullName: "org1/repo1", Owner: struct {
Login string `json:"login"`
}{Login: "org1"}},
})
case strings.HasSuffix(r.URL.Path, "/issues"):
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": 1, "number": 1, "title": "Multi-label issue",
"state": "open", "assignee": nil, "assignees": []interface{}{},
"labels": []map[string]interface{}{
{"id": 1, "name": "bug", "color": "d73a4a"},
{"id": 2, "name": "P1", "color": "ff0000"},
{"id": 3, "name": "help wanted", "color": "0e8a16"},
},
"html_url": "http://example.com/org1/repo1/issues/1",
},
})
case strings.HasSuffix(r.URL.Path, "/pulls"):
json.NewEncoder(w).Encode([]interface{}{})
case strings.HasSuffix(r.URL.Path, "/reviews"):
json.NewEncoder(w).Encode([]interface{}{})
default:
json.NewEncoder(w).Encode([]interface{}{})
}
}))
defer server.Close()
c := NewClient(server.URL)
queue, err := c.GetTriageQueue(context.Background(), "test-token", []string{"org1"})
if err != nil {
t.Fatalf("GetTriageQueue: %v", err)
}
if len(queue) != 1 {
t.Fatalf("expected 1 item, got %d", len(queue))
}
if len(queue[0].Labels) != 3 {
t.Errorf("expected 3 labels, got %d: %v", len(queue[0].Labels), queue[0].Labels)
}
}
// --- Issue #122: Tests for ListOrgsAndRepos and CreateIssue ---
func TestListOrgsAndRepos(t *testing.T) {
+48 -1
View File
@@ -181,11 +181,58 @@ func renderPage(w http.ResponseWriter, r *http.Request, title, activeTab string,
}
}
// errorData holds the template data for error pages.
type errorData struct {
Code int
Title string
Message string
}
// ErrorNotFound renders a mobile-friendly 404 error page.
func (h *Handler) ErrorNotFound(w http.ResponseWriter, r *http.Request) {
data := errorData{
Code: http.StatusNotFound,
Title: "Page Not Found",
Message: "The page you are looking for does not exist or has been moved.",
}
h.renderError(w, r, data)
}
// ErrorInternal renders a mobile-friendly 500 error page.
func (h *Handler) ErrorInternal(w http.ResponseWriter, r *http.Request) {
data := errorData{
Code: http.StatusInternalServerError,
Title: "Internal Server Error",
Message: "Something went wrong on our end. Please try again later.",
}
h.renderError(w, r, data)
}
// renderError renders the error template with the given data and status code.
func (h *Handler) renderError(w http.ResponseWriter, r *http.Request, data errorData) {
tmpl, err := template.ParseFiles("internal/templates/error.html")
if err != nil {
slog.Error("failed to parse error template", "error", err)
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
return
}
var buf strings.Builder
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
slog.Error("failed to execute error template", "error", err)
http.Error(w, fmt.Sprintf("%d %s", data.Code, data.Title), data.Code)
return
}
w.WriteHeader(data.Code)
renderPage(w, r, data.Title, "", buf.String())
}
// Dashboard handles GET / — the triage queue.
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
}
+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
+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}}
+106 -2
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; }
@@ -533,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;
@@ -543,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;
}