issue_label dispatch crashes on null entries in the Forgejo issue timeline #262

Closed
opened 2026-11-12 10:43:08 +01:00 by omp-deck · 0 comments
Collaborator

Problem

An issue_label / label_updated webhook delivery fails with:

null is not an object (evaluating 'c.label')

The trigger is recorded as failed with sessionId: 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:

for (const c of res.data) {
    const name = c.label?.name;
    if (c.type !== "label" || !name) continue;

The element access c.label is 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}/timeline can return null array elements: the serializer maps timeline rows it cannot convert into an API comment to nil and emits them as JSON null instead 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_label payload carries no diff of what changed, so Dispatcher.latestAddedLabels (apps/server/src/forgejo/dispatcher.ts:1079-1091) asks the API which labels the newest change added; listLabelEvents pages the timeline and throws on the first null entry; the dispatcher aborts before resolving the label trigger (resolveLabelTrigger, dispatcher.ts:1115).

Blast radius. Not a one-off: every issue_label trigger 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) declares client.json<T[]> and accumulates elements unchanged — 13 call sites.
  • Ten direct, unpaged client.json<X[]> fetches that never touch fetchAllPages and map over their elements immediately.
  • Two nested arrays, ApiCombinedStatus.statuses in dispatch-api.ts:554 and tools/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_runs is already element-safe at ci.ts:186 and 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 the fetchAllPages doc comment). No equivalent exists: the module repeats the inline idiom Array.isArray(x.data) ? x.data : [], which handles a non-array body but not null elements.

/**
 * A JSON array body as a usable list: `[]` when it is not an array, and
 * without the `null` elements Forgejo emits for rows it cannot serialise
 * (deleted comments, reviews, labels and the like). Only `null`/`undefined`
 * are dropped, so a legitimately falsy element survives.
 */
export function compactList<T>(value: unknown): T[] {
	if (!Array.isArray(value)) return [];
	return value.filter((v): v is T => v != null);
}

/**
 * One request to an endpoint whose body is a JSON array, with the elements
 * compacted by `compactList`. Error results pass through unchanged; `meta`
 * (the paging headers) is preserved.
 */
export async function jsonList<T>(
	client: ForgejoClient,
	method: string,
	path: string,
	opts: RequestOpts = {},
): Promise<ApiResult<T[]>> {
	const res = await client.json<(T | null)[]>(method, path, opts);
	if (!res.ok) return res;
	return { ok: true, meta: res.meta, data: compactList<T>(res.data) };
}

RequestOpts is exported from ../client.ts (it is the third parameter type of ForgejoClient.json, client.ts:203); extend the existing type-only import on shared.ts:26 to include it. If RequestOpts turns out not to be exported, export it from client.ts rather than restating its shape.

Step 2 — route fetchAllPages through compactList, 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.

const res = await client.json<(T | null)[]>("GET", path, {
    query: { ...opts.query, page, limit: pageSize },
    signal: opts.signal,
});
if (!res.ok) return res;
const items = Array.isArray(res.data) ? res.data : [];
all.push(...compactList<T>(items));
if (items.length < pageSize || !hasMorePages(res.meta, page, pageSize)) break;

Do not call jsonList here — it would discard the raw length. This step alone fixes the reported crash for all 13 fetchAllPages consumers; no call site changes.

Step 3 — convert every direct array fetch to jsonList. Mechanical and independent of each other; each one replaces client.json<X[]>("GET"|"PUT", path, opts) with jsonList<X>(client, "GET"|"PUT", path, opts) (note the element type loses its []), and the Array.isArray(<res>.data) ? <res>.data : [] line that immediately follows becomes a plain <res>.data. Error handling at each site is unchanged — jsonList returns the same error variant, so existing if (!res.ok), res.status === 404 and apiError(res) branches keep working.

File Line Current element type Follow-up Array.isArray guard to drop
forgejo/dispatch-api.ts 299 ApiReviewComment[] line 303 if (!Array.isArray(inline.data)) continue; — delete the line
forgejo/dispatch-api.ts 320 ApiReviewComment[] line 326
forgejo/dispatch-api.ts 402 RawIssue[] line 407
forgejo/tools/issues.ts 76 ApiComment[] line 78
forgejo/tools/issues.ts 104 ApiIssue[] line 116
forgejo/tools/issues.ts 263 (PUT) ApiLabel[] line 272
forgejo/tools/prs.ts 75 ApiReviewComment[] whichever guard follows in fetchInlineComments
forgejo/tools/prs.ts 308 ApiComment[] line 319 Array.isArray(comments.data) ? comments.data : []
forgejo/tools/prs.ts 609 ApiPull[] line 614
forgejo/tools/prs.ts 624 ApiIssue[] line 629
forgejo/tools/ci.ts 381 ApiActionRunJob[] line 386
forgejo/tools/ci.ts 431 ApiActionRunJob[] line 435
forgejo/tools/pr-diff.ts 221 ApiCommit[] line 226 (Array.isArray(res.data) ? res.data[0]?.sha : undefined → res.data[0]?.sha)

In dispatch-api.ts the helper is called as jsonList<X>(client, …); in tools/*.ts as jsonList<X>(ctx.client, …). Add jsonList to 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 compactList to the two nested statuses arrays.

  • forgejo/dispatch-api.ts:554: let statuses: ApiCommitStatus[] = compactList<ApiCommitStatus>(combined.data.statuses);
  • forgejo/tools/ci.ts:162: the same replacement for combined.data.statuses ?? [].

Both then dereference elements (s.context, s.status, s.target_url) safely. Import compactList alongside the existing shared.ts imports in both files.

Step 5 — make createDispatchApi accept an injected client so listLabelEvents is testable.

createDispatchApi() (dispatch-api.ts:262-265) builds its own ForgejoClient from readForgejoConfig(), which reads the DB; nothing can reach the real listLabelEvents loop 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 unless openDb() ran, and db/index.ts opens nothing at import time):

/**
 * A `DispatchApi` for the configured instance, or null when Forgejo is not
 * configured. `injected` overrides the configured connection (tests); with
 * it, the stored config is not read and the result is never null.
 */
export function createDispatchApi(injected?: ForgejoClient): DispatchApi | null {
	let client = injected;
	if (!client) {
		const cfg = readForgejoConfig();
		if (!cfg) return null;
		client = new ForgejoClient({ url: cfg.url, token: cfg.token });
	}
	return {
		…
	};
}

Naming the parameter injected keeps client as the identifier the returned object's methods already close over, so no line inside the returned object changes. client is ForgejoClient | undefined after the let, but narrowed by the if block before the return; if the narrowing does not survive into the closures, bind const api = client; after the block and rename the closure uses in one pass. Existing callers (repo-system-prompt.ts:2 and 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 at bridge/git-tools/repo-info.test.ts:40:

const stub = (pages: unknown[][]) => {
	let i = 0;
	return {
		json: async () => ({ ok: true, data: pages[Math.min(i++, pages.length - 1)], meta: { totalCount: null, hasNext: i < pages.length } }),
	} as unknown as ForgejoClient;
};

Tests:

  1. compactList drops null/undefined, keeps order, keeps 0 and "", and returns [] for a non-array (undefined, an object).
  2. jsonList over a body [{id:1}, null, {id:2}] resolves {ok: true} with data [{id:1},{id:2}] and the stub's meta intact; an {ok:false, status:404} body passes through unchanged.
  3. fetchAllPages paging is driven by the raw page length: with pageSize: 2 and 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:

const timeline = [
	{ type: "label", body: "1", created_at: "2026-11-01T10:00:00Z", user: { login: "alice" }, label: { name: "Ready" } },
	null,
	{ type: "label", body: "0", created_at: "2026-11-01T10:00:01Z", user: { login: "bob" }, label: { name: "Draft" } },
];

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 (pageSize elements, 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:162 and apps/server/src/forgejo/dispatch-api.ts:554 — the nested statuses arrays, the only element dereferences a list helper cannot reach.
  • apps/server/src/forgejo/client.ts:25-32,203 — PageMeta, ApiResult, ForgejoClient.json; the shapes jsonList must preserve.

Verification

From apps/server:

  1. 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 containing null yields both surrounding label events in order instead of throwing null is not an object (evaluating 'c.label').
  2. bun test — the full server suite, including dispatcher.test.ts (which fakes DispatchApi wholesale and must be untouched by the signature change) and tools/*.test.ts for the converted call sites.
  3. bun run typecheck from the repo root — proves every converted call site passes jsonList the element type rather than the array type, and that no caller of createDispatchApi broke.

Assumptions & contingencies

  • The null elements are assumed to carry no information worth recovering (they are rows Forgejo itself could not serialise), so they are dropped silently, with no logging added to tools/shared.ts (which currently imports no logger). If a later investigation needs to see them, the single place to add a counter is compactList.
  • If RequestOpts is not exported from client.ts, export it there rather than duplicating the option shape in shared.ts.
  • If any direct call site in Step 3 turns out to use res.data in a way that depends on positional alignment with another array (none does in the current tree — the mapLimit sites align result arrays, not elements within a body), leave that site on client.json and note it, rather than changing its semantics.
## Problem An `issue_label` / `label_updated` webhook delivery fails with: ``` null is not an object (evaluating 'c.label') ``` The trigger is recorded as `failed` with `sessionId: 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`: ```ts for (const c of res.data) { const name = c.label?.name; if (c.type !== "label" || !name) continue; ``` The element access `c.label` is 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}/timeline` can return `null` array elements: the serializer maps timeline rows it cannot convert into an API comment to nil and emits them as JSON `null` instead 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_label` payload carries no diff of what changed, so `Dispatcher.latestAddedLabels` (`apps/server/src/forgejo/dispatcher.ts:1079-1091`) asks the API which labels the newest change added; `listLabelEvents` pages the timeline and throws on the first `null` entry; the dispatcher aborts before resolving the label trigger (`resolveLabelTrigger`, `dispatcher.ts:1115`). **Blast radius.** Not a one-off: *every* `issue_label` trigger 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`) declares `client.json<T[]>` and accumulates elements unchanged — 13 call sites. - Ten direct, unpaged `client.json<X[]>` fetches that never touch `fetchAllPages` and map over their elements immediately. - Two nested arrays, `ApiCombinedStatus.statuses` in `dispatch-api.ts:554` and `tools/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_runs` is already element-safe at `ci.ts:186` and 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 the `fetchAllPages` doc comment). No equivalent exists: the module repeats the inline idiom `Array.isArray(x.data) ? x.data : []`, which handles a non-array body but not null elements. ```ts /** * A JSON array body as a usable list: `[]` when it is not an array, and * without the `null` elements Forgejo emits for rows it cannot serialise * (deleted comments, reviews, labels and the like). Only `null`/`undefined` * are dropped, so a legitimately falsy element survives. */ export function compactList<T>(value: unknown): T[] { if (!Array.isArray(value)) return []; return value.filter((v): v is T => v != null); } /** * One request to an endpoint whose body is a JSON array, with the elements * compacted by `compactList`. Error results pass through unchanged; `meta` * (the paging headers) is preserved. */ export async function jsonList<T>( client: ForgejoClient, method: string, path: string, opts: RequestOpts = {}, ): Promise<ApiResult<T[]>> { const res = await client.json<(T | null)[]>(method, path, opts); if (!res.ok) return res; return { ok: true, meta: res.meta, data: compactList<T>(res.data) }; } ``` `RequestOpts` is exported from `../client.ts` (it is the third parameter type of `ForgejoClient.json`, `client.ts:203`); extend the existing type-only import on `shared.ts:26` to include it. If `RequestOpts` turns out not to be exported, export it from `client.ts` rather than restating its shape. **Step 2 — route `fetchAllPages` through `compactList`, 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. ```ts const res = await client.json<(T | null)[]>("GET", path, { query: { ...opts.query, page, limit: pageSize }, signal: opts.signal, }); if (!res.ok) return res; const items = Array.isArray(res.data) ? res.data : []; all.push(...compactList<T>(items)); if (items.length < pageSize || !hasMorePages(res.meta, page, pageSize)) break; ``` Do not call `jsonList` here — it would discard the raw length. This step alone fixes the reported crash for all 13 `fetchAllPages` consumers; no call site changes. **Step 3 — convert every direct array fetch to `jsonList`.** Mechanical and independent of each other; each one replaces `client.json<X[]>("GET"|"PUT", path, opts)` with `jsonList<X>(client, "GET"|"PUT", path, opts)` (note the element type loses its `[]`), and the `Array.isArray(<res>.data) ? <res>.data : []` line that immediately follows becomes a plain `<res>.data`. Error handling at each site is unchanged — `jsonList` returns the same error variant, so existing `if (!res.ok)`, `res.status === 404` and `apiError(res)` branches keep working. | File | Line | Current element type | Follow-up `Array.isArray` guard to drop | | --- | --- | --- | --- | | `forgejo/dispatch-api.ts` | 299 | `ApiReviewComment[]` | line 303 `if (!Array.isArray(inline.data)) continue;` — delete the line | | `forgejo/dispatch-api.ts` | 320 | `ApiReviewComment[]` | line 326 | | `forgejo/dispatch-api.ts` | 402 | `RawIssue[]` | line 407 | | `forgejo/tools/issues.ts` | 76 | `ApiComment[]` | line 78 | | `forgejo/tools/issues.ts` | 104 | `ApiIssue[]` | line 116 | | `forgejo/tools/issues.ts` | 263 (`PUT`) | `ApiLabel[]` | line 272 | | `forgejo/tools/prs.ts` | 75 | `ApiReviewComment[]` | whichever guard follows in `fetchInlineComments` | | `forgejo/tools/prs.ts` | 308 | `ApiComment[]` | line 319 `Array.isArray(comments.data) ? comments.data : []` | | `forgejo/tools/prs.ts` | 609 | `ApiPull[]` | line 614 | | `forgejo/tools/prs.ts` | 624 | `ApiIssue[]` | line 629 | | `forgejo/tools/ci.ts` | 381 | `ApiActionRunJob[]` | line 386 | | `forgejo/tools/ci.ts` | 431 | `ApiActionRunJob[]` | line 435 | | `forgejo/tools/pr-diff.ts` | 221 | `ApiCommit[]` | line 226 (`Array.isArray(res.data) ? res.data[0]?.sha : undefined` → `res.data[0]?.sha`) | In `dispatch-api.ts` the helper is called as `jsonList<X>(client, …)`; in `tools/*.ts` as `jsonList<X>(ctx.client, …)`. Add `jsonList` to 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 `compactList` to the two nested `statuses` arrays.** - `forgejo/dispatch-api.ts:554`: `let statuses: ApiCommitStatus[] = compactList<ApiCommitStatus>(combined.data.statuses);` - `forgejo/tools/ci.ts:162`: the same replacement for `combined.data.statuses ?? []`. Both then dereference elements (`s.context`, `s.status`, `s.target_url`) safely. Import `compactList` alongside the existing `shared.ts` imports in both files. **Step 5 — make `createDispatchApi` accept an injected client so `listLabelEvents` is testable.** `createDispatchApi()` (`dispatch-api.ts:262-265`) builds its own `ForgejoClient` from `readForgejoConfig()`, which reads the DB; nothing can reach the real `listLabelEvents` loop 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 unless `openDb()` ran, and `db/index.ts` opens nothing at import time): ```ts /** * A `DispatchApi` for the configured instance, or null when Forgejo is not * configured. `injected` overrides the configured connection (tests); with * it, the stored config is not read and the result is never null. */ export function createDispatchApi(injected?: ForgejoClient): DispatchApi | null { let client = injected; if (!client) { const cfg = readForgejoConfig(); if (!cfg) return null; client = new ForgejoClient({ url: cfg.url, token: cfg.token }); } return { … }; } ``` Naming the parameter `injected` keeps `client` as the identifier the returned object's methods already close over, so no line inside the returned object changes. `client` is `ForgejoClient | undefined` after the `let`, but narrowed by the `if` block before the `return`; if the narrowing does not survive into the closures, bind `const api = client;` after the block and rename the closure uses in one pass. Existing callers (`repo-system-prompt.ts:2` and 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 at `bridge/git-tools/repo-info.test.ts:40`: ```ts const stub = (pages: unknown[][]) => { let i = 0; return { json: async () => ({ ok: true, data: pages[Math.min(i++, pages.length - 1)], meta: { totalCount: null, hasNext: i < pages.length } }), } as unknown as ForgejoClient; }; ``` Tests: 1. `compactList` drops `null`/`undefined`, keeps order, keeps `0` and `""`, and returns `[]` for a non-array (`undefined`, an object). 2. `jsonList` over a body `[{id:1}, null, {id:2}]` resolves `{ok: true}` with `data` `[{id:1},{id:2}]` and the stub's `meta` intact; an `{ok:false, status:404}` body passes through unchanged. 3. `fetchAllPages` paging is driven by the raw page length: with `pageSize: 2` and 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: ```ts const timeline = [ { type: "label", body: "1", created_at: "2026-11-01T10:00:00Z", user: { login: "alice" }, label: { name: "Ready" } }, null, { type: "label", body: "0", created_at: "2026-11-01T10:00:01Z", user: { login: "bob" }, label: { name: "Draft" } }, ]; ``` 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 (`pageSize` elements, 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:162` and `apps/server/src/forgejo/dispatch-api.ts:554` — the nested `statuses` arrays, the only element dereferences a list helper cannot reach. - `apps/server/src/forgejo/client.ts:25-32,203` — `PageMeta`, `ApiResult`, `ForgejoClient.json`; the shapes `jsonList` must preserve. ### Verification From `apps/server`: 1. `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 containing `null` yields both surrounding label events in order instead of throwing `null is not an object (evaluating 'c.label')`. 2. `bun test` — the full server suite, including `dispatcher.test.ts` (which fakes `DispatchApi` wholesale and must be untouched by the signature change) and `tools/*.test.ts` for the converted call sites. 3. `bun run typecheck` from the repo root — proves every converted call site passes `jsonList` the element type rather than the array type, and that no caller of `createDispatchApi` broke. ### Assumptions & contingencies - The null elements are assumed to carry no information worth recovering (they are rows Forgejo itself could not serialise), so they are dropped silently, with no logging added to `tools/shared.ts` (which currently imports no logger). If a later investigation needs to see them, the single place to add a counter is `compactList`. - If `RequestOpts` is not exported from `client.ts`, export it there rather than duplicating the option shape in `shared.ts`. - If any direct call site in Step 3 turns out to use `res.data` in a way that depends on positional alignment with another array (none does in the current tree — the `mapLimit` sites align *result* arrays, not elements within a body), leave that site on `client.json` and note it, rather than changing its semantics.
lucas closed this issue 2026-11-12 13:10:44 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#262
No description provided.