issue_label dispatch crashes on null entries in the Forgejo issue timeline #262
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#262
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
An
issue_label/label_updatedwebhook delivery fails with:The trigger is recorded as
failedwithsessionId: null— no agent session is ever started, so label-driven intake silently does not happen for the affected issue.Cause.
apps/server/src/forgejo/dispatch-api.ts:535-549,listLabelEvents:The element access
c.labelis unguarded; only the property below it is optional-chained.RawTimelineComment(same file, line 121) declares every field optional but the element as non-nullable, so TypeScript does not flag it.Forgejo's
GET /repos/{owner}/{repo}/issues/{n}/timelinecan returnnullarray elements: the serializer maps timeline rows it cannot convert into an API comment to nil and emits them as JSONnullinstead of dropping them (unverified — Forgejo's source is not reachable from this repo; the observed crash is the evidence). Typical sources are rows whose referenced object no longer exists (deleted comment/review, removed label, dependency on a deleted issue). One such element makes the loop throw.Path to the failure. The
issue_labelpayload carries no diff of what changed, soDispatcher.latestAddedLabels(apps/server/src/forgejo/dispatcher.ts:1079-1091) asks the API which labels the newest change added;listLabelEventspages the timeline and throws on the firstnullentry; the dispatcher aborts before resolving the label trigger (resolveLabelTrigger,dispatcher.ts:1115).Blast radius. Not a one-off: every
issue_labeltrigger on an issue whose timeline contains a null entry fails the same way, permanently, until the loop skips null entries. Long-lived issues with edited/deleted history are the likely population.The same unguarded-element assumption exists across the whole Forgejo module, in three shapes, all verified in the current tree:
fetchAllPages<T>(apps/server/src/forgejo/tools/shared.ts:236-255) declaresclient.json<T[]>and accumulates elements unchanged — 13 call sites.client.json<X[]>fetches that never touchfetchAllPagesand map over their elements immediately.ApiCombinedStatus.statusesindispatch-api.ts:554andtools/ci.ts:162, whose elements are dereferenced right away.The fix must therefore be one shared mechanism, not a guard at the crash site. (
ApiActionRunList.workflow_runsis already element-safe atci.ts:186and needs no change.)Plan
Approach
Step 1 — add the null-dropping primitive and the list fetch helper in
apps/server/src/forgejo/tools/shared.ts.Insert both next to
fetchAllPages(after line 229, before thefetchAllPagesdoc comment). No equivalent exists: the module repeats the inline idiomArray.isArray(x.data) ? x.data : [], which handles a non-array body but not null elements.RequestOptsis exported from../client.ts(it is the third parameter type ofForgejoClient.json,client.ts:203); extend the existing type-only import onshared.ts:26to include it. IfRequestOptsturns out not to be exported, export it fromclient.tsrather than restating its shape.Step 2 — route
fetchAllPagesthroughcompactList, keeping the raw page length for paging.In
fetchAllPages(shared.ts:244-253), fetch each page as(T | null)[]and compact only what is accumulated. The short-page break must keep using the raw server page length: compacting first would make a full page that contains nulls look short and silently truncate paging.Do not call
jsonListhere — it would discard the raw length. This step alone fixes the reported crash for all 13fetchAllPagesconsumers; no call site changes.Step 3 — convert every direct array fetch to
jsonList. Mechanical and independent of each other; each one replacesclient.json<X[]>("GET"|"PUT", path, opts)withjsonList<X>(client, "GET"|"PUT", path, opts)(note the element type loses its[]), and theArray.isArray(<res>.data) ? <res>.data : []line that immediately follows becomes a plain<res>.data. Error handling at each site is unchanged —jsonListreturns the same error variant, so existingif (!res.ok),res.status === 404andapiError(res)branches keep working.Array.isArrayguard to dropforgejo/dispatch-api.tsApiReviewComment[]if (!Array.isArray(inline.data)) continue;— delete the lineforgejo/dispatch-api.tsApiReviewComment[]forgejo/dispatch-api.tsRawIssue[]forgejo/tools/issues.tsApiComment[]forgejo/tools/issues.tsApiIssue[]forgejo/tools/issues.tsPUT)ApiLabel[]forgejo/tools/prs.tsApiReviewComment[]fetchInlineCommentsforgejo/tools/prs.tsApiComment[]Array.isArray(comments.data) ? comments.data : []forgejo/tools/prs.tsApiPull[]forgejo/tools/prs.tsApiIssue[]forgejo/tools/ci.tsApiActionRunJob[]forgejo/tools/ci.tsApiActionRunJob[]forgejo/tools/pr-diff.tsApiCommit[]Array.isArray(res.data) ? res.data[0]?.sha : undefined→res.data[0]?.sha)In
dispatch-api.tsthe helper is called asjsonList<X>(client, …); intools/*.tsasjsonList<X>(ctx.client, …). AddjsonListto each file's existing import from./shared.ts/./tools/shared.ts.Leave single-object fetches alone (
client.json<ApiPull>,client.json<ApiActionRun>,client.json<ApiCombinedStatus>, …): they are not arrays.Step 4 — apply
compactListto the two nestedstatusesarrays.forgejo/dispatch-api.ts:554:let statuses: ApiCommitStatus[] = compactList<ApiCommitStatus>(combined.data.statuses);forgejo/tools/ci.ts:162: the same replacement forcombined.data.statuses ?? [].Both then dereference elements (
s.context,s.status,s.target_url) safely. ImportcompactListalongside the existingshared.tsimports in both files.Step 5 — make
createDispatchApiaccept an injected client solistLabelEventsis testable.createDispatchApi()(dispatch-api.ts:262-265) builds its ownForgejoClientfromreadForgejoConfig(), which reads the DB; nothing can reach the reallistLabelEventsloop today. Change the signature to take an optional client and skip the config read entirely when one is given — that keeps the test free of any DB (getDb()throws unlessopenDb()ran, anddb/index.tsopens nothing at import time):Naming the parameter
injectedkeepsclientas the identifier the returned object's methods already close over, so no line inside the returned object changes.clientisForgejoClient | undefinedafter thelet, but narrowed by theifblock before thereturn; if the narrowing does not survive into the closures, bindconst api = client;after the block and rename the closure uses in one pass. Existing callers (repo-system-prompt.ts:2and the dispatcher) pass nothing and are unaffected — the parameter is optional.Step 6 — mechanism tests in
apps/server/src/forgejo/tools/shared.test.ts.Add one
describe("list compaction")block. Stub the client with the pattern already used atbridge/git-tools/repo-info.test.ts:40:Tests:
compactListdropsnull/undefined, keeps order, keeps0and"", and returns[]for a non-array (undefined, an object).jsonListover a body[{id:1}, null, {id:2}]resolves{ok: true}withdata[{id:1},{id:2}]and the stub'smetaintact; an{ok:false, status:404}body passes through unchanged.fetchAllPagespaging is driven by the raw page length: withpageSize: 2and pages[[{id:1}, null], [{id:2}]], the result is[{id:1},{id:2}]— proving the null-bearing first page did not stop paging (if the filter leaked into the length check the result would be[{id:1}]).Step 7 — end-to-end regression test, new file
apps/server/src/forgejo/dispatch-api.test.ts.Build the API with an injected stub client and assert the reported failure is gone:
The stub returns that body for the timeline path. Assert
listLabelEvents("o", "r", 7)resolves{ok: true}(not a throw) with exactly two events, in page order,[{label: "Ready", added: true, author: "alice", …}, {label: "Draft", added: false, author: "bob", …}]. Add a second case where the null sits on a full first page (pageSizeelements, stub serving a second page) to show the null did not cut the timeline short. Document in the file header that the client is a stub and the API is built via the injected-client path.Critical files & anchors
apps/server/src/forgejo/tools/shared.ts:236-255—fetchAllPages; the raw-vs-compacted page length in the break condition is the one subtle line of the change.apps/server/src/forgejo/dispatch-api.ts:535-549—listLabelEvents, the reported crash; after Step 2 it needs no edit of its own, which is the point of the central fix.apps/server/src/forgejo/dispatch-api.ts:262-266—createDispatchApi; the config read must stay on the non-injected path only.apps/server/src/forgejo/tools/ci.ts:162andapps/server/src/forgejo/dispatch-api.ts:554— the nestedstatusesarrays, the only element dereferences a list helper cannot reach.apps/server/src/forgejo/client.ts:25-32,203—PageMeta,ApiResult,ForgejoClient.json; the shapesjsonListmust preserve.Verification
From
apps/server:bun test src/forgejo/tools/shared.test.ts src/forgejo/dispatch-api.test.ts— the new tests. The decisive assertion is Step 7's: a timeline page containingnullyields both surrounding label events in order instead of throwingnull is not an object (evaluating 'c.label').bun test— the full server suite, includingdispatcher.test.ts(which fakesDispatchApiwholesale and must be untouched by the signature change) andtools/*.test.tsfor the converted call sites.bun run typecheckfrom the repo root — proves every converted call site passesjsonListthe element type rather than the array type, and that no caller ofcreateDispatchApibroke.Assumptions & contingencies
tools/shared.ts(which currently imports no logger). If a later investigation needs to see them, the single place to add a counter iscompactList.RequestOptsis not exported fromclient.ts, export it there rather than duplicating the option shape inshared.ts.res.datain a way that depends on positional alignment with another array (none does in the current tree — themapLimitsites align result arrays, not elements within a body), leave that site onclient.jsonand note it, rather than changing its semantics.