diff --git a/codev/projects/bugfix-749-gitea-forge-500-error-bug-with/status.yaml b/codev/projects/bugfix-749-gitea-forge-500-error-bug-with/status.yaml new file mode 100644 index 000000000..b258265d3 --- /dev/null +++ b/codev/projects/bugfix-749-gitea-forge-500-error-bug-with/status.yaml @@ -0,0 +1,12 @@ +id: bugfix-749 +title: gitea-forge-500-error-bug-with +protocol: bugfix +phase: pr +plan_phases: [] +current_plan_phase: null +gates: {} +iteration: 1 +build_complete: false +history: [] +started_at: '2026-05-19T00:32:06.931Z' +updated_at: '2026-05-19T00:38:33.632Z' diff --git a/packages/codev/scripts/forge/gitea/issue-list.sh b/packages/codev/scripts/forge/gitea/issue-list.sh index f1c565453..a8bc246fe 100755 --- a/packages/codev/scripts/forge/gitea/issue-list.sh +++ b/packages/codev/scripts/forge/gitea/issue-list.sh @@ -1,3 +1,26 @@ #!/bin/sh # Forge concept: issue-list (Gitea via tea CLI) -exec tea issues list --limit 200 --output json +# +# tea's default JSON output uses fields that don't match the GitHub-compatible +# shape codev's overview expects (see codev/src/lib/forge-contracts.ts). +# Normalize via jq so the same overview code path works for both forges: +# index -> number (int) +# created -> createdAt +# author (string) -> author.login +# labels (CSV) -> labels[].name +# assignees (CSV) -> assignees[].login +exec tea issues list --limit 200 \ + --fields index,title,state,author,url,created,labels,assignees \ + --output json \ + | jq '[.[] | { + number: (.index | tonumber), + title, + state, + url, + createdAt: .created, + author: {login: .author}, + labels: (if (.labels // "") == "" then [] + else (.labels | split(",") | map({name: ltrimstr(" ")})) end), + assignees: (if (.assignees // "") == "" then [] + else (.assignees | split(",") | map({login: ltrimstr(" ")})) end) + }]' diff --git a/packages/codev/scripts/forge/gitea/pr-list.sh b/packages/codev/scripts/forge/gitea/pr-list.sh index 70f3c1901..90c52a9fa 100755 --- a/packages/codev/scripts/forge/gitea/pr-list.sh +++ b/packages/codev/scripts/forge/gitea/pr-list.sh @@ -1,3 +1,23 @@ #!/bin/sh # Forge concept: pr-list (Gitea via tea CLI) -exec tea pulls list --output json +# +# Normalize tea's PR shape to the GitHub-compatible shape codev expects +# (see PrListItem in codev/src/lib/forge-contracts.ts): +# index -> number (int) +# description -> body +# created -> createdAt +# author (string) -> author.login +# reviewDecision -> "" (Gitea has no GitHub-equivalent review-decision summary) +exec tea pulls list --limit 200 \ + --fields index,title,state,author,url,created,description \ + --output json \ + | jq '[.[] | { + number: (.index | tonumber), + title, + state, + url, + reviewDecision: "", + body: (.description // ""), + createdAt: .created, + author: {login: .author} + }]' diff --git a/packages/codev/scripts/forge/gitea/recently-closed.sh b/packages/codev/scripts/forge/gitea/recently-closed.sh index f7da1bbf4..1cd91e81e 100755 --- a/packages/codev/scripts/forge/gitea/recently-closed.sh +++ b/packages/codev/scripts/forge/gitea/recently-closed.sh @@ -1,3 +1,21 @@ #!/bin/sh # Forge concept: recently-closed (Gitea via tea CLI) -exec tea issues list --state closed --limit 1000 --output json +# +# Normalize to GitHub-compatible shape (see IssueListItem in forge-contracts.ts). +# Gitea exposes no separate `closed_at` field on issue list output, so we map +# `updated` -> `closedAt`. For issues closed without subsequent edits this is +# exactly the close time; for issues edited after close it overestimates, which +# is acceptable for the "recently closed" overview filter. +exec tea issues list --state closed --limit 1000 \ + --fields index,title,state,author,url,created,updated,labels \ + --output json \ + | jq '[.[] | { + number: (.index | tonumber), + title, + state, + url, + createdAt: .created, + closedAt: .updated, + labels: (if (.labels // "") == "" then [] + else (.labels | split(",") | map({name: ltrimstr(" ")})) end) + }]' diff --git a/packages/codev/scripts/forge/gitea/recently-merged.sh b/packages/codev/scripts/forge/gitea/recently-merged.sh index 0f5815a65..4c2d30955 100755 --- a/packages/codev/scripts/forge/gitea/recently-merged.sh +++ b/packages/codev/scripts/forge/gitea/recently-merged.sh @@ -1,3 +1,27 @@ #!/bin/sh # Forge concept: recently-merged (Gitea via tea CLI) -exec tea pulls list --state closed --limit 1000 --output json +# +# `tea pulls list --state closed` returns both merged PRs and closed-without- +# merge PRs. Filter to merged only via `.merged == true` (the same predicate +# scripts/forge/gitea/pr-exists.sh already relies on), then map to the +# GitHub-compatible shape: +# index -> number (int) +# created -> createdAt +# updated -> mergedAt (tea exposes no merged_at field via --fields; +# close-then-edit overestimates merged time +# but is acceptable for the 24h overview window) +# head.ref -> headRefName +# description -> body +exec tea pulls list --state closed --limit 1000 \ + --fields index,title,state,author,url,created,updated,head,description,merged \ + --output json \ + | jq '[.[] | select(.merged == true) | { + number: (.index | tonumber), + title, + state, + url, + body: (.description // ""), + createdAt: .created, + mergedAt: .updated, + headRefName: (.head.ref // "") + }]' diff --git a/packages/codev/src/__tests__/github.test.ts b/packages/codev/src/__tests__/github.test.ts index d186fa3a9..d4268498a 100644 --- a/packages/codev/src/__tests__/github.test.ts +++ b/packages/codev/src/__tests__/github.test.ts @@ -258,6 +258,37 @@ describe('parseLabelDefaults', () => { expect(parseLabelDefaults([], 'Create issue template').type).toBe('project'); expect(parseLabelDefaults([], 'Improve issue search').type).toBe('project'); }); + + // Regression: issue #749 — Gitea/Forgejo returns `labels: ""` or `null` for + // unlabeled issues, where GitHub always returns []. parseLabelDefaults used + // to crash with "labels.map is not a function" and 500 the Tower overview. + it('coerces empty-string labels (Gitea/Forgejo) to no-labels result', () => { + expect(parseLabelDefaults('', 'Fix login bug')).toEqual({ + type: 'bug', + priority: 'medium', + }); + }); + + it('coerces null labels to no-labels result', () => { + expect(parseLabelDefaults(null)).toEqual({ + type: 'project', + priority: 'medium', + }); + }); + + it('coerces undefined labels to no-labels result', () => { + expect(parseLabelDefaults(undefined, 'Add dark mode')).toEqual({ + type: 'project', + priority: 'medium', + }); + }); + + it('still extracts type from a real label array (GitHub path)', () => { + expect(parseLabelDefaults([{ name: 'type:bug' }])).toEqual({ + type: 'bug', + priority: 'medium', + }); + }); }); // ============================================================================= diff --git a/packages/codev/src/lib/github.ts b/packages/codev/src/lib/github.ts index 2b539e30a..5c8bc927b 100644 --- a/packages/codev/src/lib/github.ts +++ b/packages/codev/src/lib/github.ts @@ -466,13 +466,17 @@ const BARE_TYPE_LABELS = new Set(['bug', 'project', 'spike']); const BUG_TITLE_PATTERNS = /\b(fix|bug|broken|error|crash|fail|wrong|regression|not working)/i; export function parseLabelDefaults( - labels: Array<{ name: string }>, + labels: Array<{ name: string }> | null | undefined | string, title?: string, ): { type: string; priority: string; } { - const names = labels.map(l => l.name); + // Forge providers vary: GitHub returns an array of {name} objects, while + // Gitea/Forgejo returns "" (empty string) or null when an issue has no + // labels. Coerce non-array inputs to [] so the array methods below can't + // throw "labels.map is not a function" in non-GitHub forges. + const names = Array.isArray(labels) ? labels.map(l => l.name) : []; const typeLabels = names .filter(n => n.startsWith('type:'))