Files
fusion/packages/dashboard/src/github-tracking-state.ts
gsxdsm 74cba4b46d batch-core: one shared landed-lane helper for the source-issue surfaces (75 → 72) (#2783)
## batch-core continued — the source-issue cluster

Follow-on to #2780 (merged). Scope is still `packages/core` +
`packages/dashboard/src`.

### The defect

Five places asked the same question — *has this task landed?* — and all
five compared against the literal `done`:

| surface | consequence on a renamed board |
|---|---|
| GitHub source-issue commenter | never comments on or closes the source
issue |
| GitLab source-issue commenter | same |
| GitLab `closedAt` backfill reconciler | finds nothing, reports a clean
scan |
| session-diff boundary | finished tasks diff against an already-merged
branch |
| tracking-comment transition | (already converted; left alone) |

The commenters are the sharpest case: they returned **before reading a
single setting**, so on a renamed board the feature looked *disabled*
rather than broken — an operator checking `githubCommentOnDone` would
see it enabled and still get nothing.

The backfill is the quietest: `scanned: N, filled: 0` reads as "nothing
to do", so the failure was indistinguishable from success.

### The fix

One home: `packages/dashboard/src/task-lifecycle-lanes.ts`. Callers now
only ask.

Five copies of one question is exactly how the halves drift apart — the
motivating incident is FN-6115 → FN-6118 → FN-6123, where the same
affordance was fixed three times because it lived in two components.
This also folds in the duplicate landed-lane helper I had left in
`register-session-diff-routes.ts` in the previous PR, which was the
sixth copy waiting to happen.

Two helpers, and the difference is deliberate:

- **`landedColumnsForTask`** — `complete ∪ archived`. Membership, since
a board may declare more than one column carrying either role, and
`columnsWithFlag(...)[0]` would silently ignore the second.
- **`completeColumnsForTask`** — complete only. The GitLab backfill's
own FNXC note records that archived tasks live in `archiveDb` and are
*intentionally* excluded, so it must not widen to the archived role just
because the shared helper offers it. Today it lists with
`includeArchived: false` and would see no archived rows either way — but
that is an incidental property of the query, not the contract. The test
pins the difference so the two are not later "simplified" into one,
which would change that caller's behaviour without touching it.

Both treat an **empty** resolved set as *unexpressed*, not absent — the
v1 hazard: `synthesizeDefaultColumns` upgrades a v1 graph with `traits:
[]` on every column, so reading empty as "no complete lane" would stop
these surfaces firing on every pre-v2 project.

The reconciler is two-stage on purpose: the cheap provider and
`closedAt` tests run first and reject almost everything, so a workflow
read only happens for real candidates, and it shares one IR cache across
the scan — one read per distinct workflow rather than per task.

### Census

`batch-core` scope **75 → 72**; repo total **338**.

### Verification

- `pnpm --filter @fusion/dashboard exec tsc --noEmit -p tsconfig.json` →
0 errors
- `pnpm lint` → 0 errors
- commenter + reconciler suites → **63 passed**; helper suite → **5
passed**
- **Mutation-verified:** making the helper ignore its resolved set fails
1 of 5.

---

## Round 2 — server.ts, chat.ts, and a correction

**Census: 75 → 67** across this PR.

### The correction (see the review thread above)

My first pass gated the source-issue commenters on
`landedColumnsForTask` (`complete ∪ archived`), which **widened** the
trigger — `to === "done"` never fired on archival, and the landed set
does. Both commenters now use `completeColumnsForTask`, and the unused
`hasTaskLanded` wrapper is gone.

The ratchet for it is pinned on the **default** board, deliberately: a
widening is visible exactly where the legacy names still apply, so no
renamed-board fixture would catch it.

### `chat.ts` — three sites, and a pair that had to move together

- **Chat verification** required `column === "in-progress"`, so on a
renamed board every chat-driven verification was refused with a message
naming a column the board does not have.
- **The planner refinement pair.** Two separate guards decide this
feature: `createSession` *registers* the tool only for a finished task,
and the tool's own `execute()` *refuses* a non-finished source. Both
compared `done`. Converting only one half would have offered the tool
and then had it refuse itself — the half-converted-pair shape. The new
test asserts **both** halves in one case (tool present *and* refinement
created), and each half reverted independently fails it.

Existing `chat-manager` coverage caught neither revert, which is why the
case exists rather than relying on the suite that was already there.

Complete-only again, not the landed set: an archived task is off the
board and is not a refinement source.

### `server.ts`

- **Planner-chat retention** — the archival cutoff was a literal, so on
a renamed board task-planner chat sessions were retained forever; the
rule this listener exists to enforce never fired. Resolved, and awaited
inside the existing fire-and-forget chain rather than by making the
listener `async` — `task:moved` has synchronous subscribers whose
ordering is load-bearing elsewhere, and a chat-row delete is not the
right place to introduce a microtask boundary into that emit.

- **`isBadgeEligibleTask` — deliberately NOT converted, and marked as
backlog.** On a renamed board it is genuinely wrong: an archived card
stays badge-eligible, its snapshot is never evicted, and the cache grows
for the daemon's lifetime — the exact memory leak the predicate was
added to fix, back under a different column name.

What blocks it is measured, not assumed: both callers are synchronous
`task:updated` / `task:created` listeners whose next statement is
documented as *"Update local cache immediately"*, so awaiting lets a
second event for the same task interleave between the eligibility check
and the cache write.

I did **not** add an optional `archivedColumns` parameter, because
nothing could fill it — the callers are the sync listeners. That is the
inert-injection shape this PR's own review caught twice on #2780: the
predicate would read as converted, its test would pass by injecting the
value, and production would keep the literal. The unblocking change (a
resolved-archived-lane cache on the badge-snapshot scope, keeping the
predicate synchronous) is recorded at the site.

### Verification

- `tsc --noEmit` → 0 errors; `pnpm lint` → 0 errors
- `chat-manager` → 101 passed; commenter/reconciler/helper/badge suites
→ 55 passed
- Mutation-verified per fix, including each half of the refinement pair
separately

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved task lifecycle handling for renamed workflow lanes, including
completed, archived, landed, and in-progress states.
* Task lists now exclude completed tasks regardless of the completion
lane’s name.
* Chat verification and refinement actions now recognize configured
workflow lanes.
* GitHub and GitLab completion comments trigger only for genuinely
completed tasks, not archived tasks.
* Knowledge index refreshes and GitLab metadata updates now support
custom completion lanes.
* **Tests**
* Added regression coverage for renamed completion lanes and
archived-task behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:03:07 -07:00

560 lines
22 KiB
TypeScript

import { createLogger } from "@fusion/core";
const severityAuditLog = createLogger("dashboard-github-tracking-state");
import type { GithubIssueAction, GlobalSettings, ProjectSettings, Task, TaskStore } from "@fusion/core";
import { columnsWithFlag, declaresAnyLifecycleTrait, resolveWorkflowIrForTask } from "@fusion/core";
import { GitHubClient } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
const TRANSIENT_RETRY_DELAY_MS = 25;
interface TaskMovedEvent {
task: {
id: string;
githubTracking?: {
enabled?: boolean;
issue?: {
owner?: string;
repo?: string;
number?: number;
url?: string;
htmlUrl?: string;
createdAt?: string;
};
};
};
// #1403: the store's `task:moved` event carries `ColumnId` (custom column ids
// admitted). U12 re-keys the decision on the complete/archived traits, so a
// workflow-defined terminal column maps to GitHub state like `done` does.
from: string;
to: string;
}
/*
FNXC:WorkflowColumns 2026-07-19-2b:50 (U12 / R2):
GitHub open/closed state keys on the `complete` and `archived` TRAITS, not the literal ids `done`
and `archived`. A user-authored workflow whose terminal column is called something else never
closed its linked GitHub issue, and a custom archive column never mapped to `not_planned`.
`classify` is injected rather than resolved here so this stays a pure decision function (the
caller owns IR resolution). Its default reproduces the legacy literal mapping exactly, so every
existing caller and the default workflow are byte-identical.
*/
export interface ColumnLifecycleClass {
complete: boolean;
archived: boolean;
}
/*
FNXC:WorkflowResolvedColumns 2026-07-31-10:15 DELIBERATE-LITERAL:
The named legacy default of the injected-classifier seam, and the only place these two ids remain in this
file. It is the answer when no workflow can be resolved — the documented degraded mode — not an
unconverted guard: `decideIssueAction`'s `classify` parameter defaults to it so a caller without an IR
keeps today's mapping exactly.
Marked deliberate only now that the production caller actually passes a RESOLVED classifier. Before that
this default was the live path on every move, and exempting it would have hidden the real defect behind a
marker — which is why the wiring change and this marker are in the same commit.
*/
export const legacyColumnLifecycleClass = (columnId: string): ColumnLifecycleClass => ({
complete: columnId === "done",
archived: columnId === "archived",
});
export function decideIssueAction(
from: string,
to: string,
classify: (columnId: string) => ColumnLifecycleClass = legacyColumnLifecycleClass,
): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null {
const fromClass = classify(from);
const toClass = classify(to);
// Un-archiving back into the completed column re-opens the issue.
if (fromClass.archived && toClass.complete) {
return { action: "reopen", stateReason: "reopened" };
}
if (toClass.complete && !fromClass.complete) {
return { action: "close", stateReason: "completed" };
}
if (toClass.archived) {
if (fromClass.complete) {
return { action: "close", stateReason: "completed" };
}
if (!fromClass.archived) {
return { action: "close", stateReason: "not_planned" };
}
return null;
}
// Leaving the completed column re-opens the issue.
if (fromClass.complete && !toClass.complete) {
return { action: "reopen", stateReason: "reopened" };
}
return null;
}
export function isTransientGitHubError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
const status = (error as Error & { status?: number; statusCode?: number }).status
?? (error as Error & { status?: number; statusCode?: number }).statusCode;
return (typeof status === "number" && status >= 500)
|| message.includes("econn")
|| message.includes("timed out")
|| message.includes("socket hang up");
}
export async function delay(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
type GitHubIssueActionEvent = {
taskId: string;
action: "close" | "reopen" | "delete" | "leave";
owner: string;
repo: string;
number: number;
outcome: "success" | "failed" | "skipped";
error?: string;
};
export class GitHubTrackingStateService {
private readonly defaultStore: TaskStore;
private readonly listeners = new Map<TaskStore, {
onTaskMoved: (event: TaskMovedEvent) => void;
onTaskDeleted: (task: Task, meta?: { githubIssueAction?: GithubIssueAction }) => void;
}>();
private started = false;
constructor(store: TaskStore) {
this.defaultStore = store;
}
start(): void {
if (this.started) return;
this.started = true;
this.attach(this.defaultStore);
}
stop(): void {
if (!this.started) return;
this.started = false;
for (const store of this.listeners.keys()) {
this.detach(store);
}
}
attach(store: TaskStore): void {
if (this.listeners.has(store)) {
return;
}
const onTaskMoved = (event: TaskMovedEvent): void => {
void this.handleTaskMoved(store, event);
};
const onTaskDeleted = (task: Task, meta?: { githubIssueAction?: GithubIssueAction }): void => {
void this.handleTaskDeleted(store, task, meta);
};
this.listeners.set(store, { onTaskMoved, onTaskDeleted });
if (this.started) {
store.on("task:moved", onTaskMoved);
store.on("task:deleted", onTaskDeleted);
}
}
detach(store: TaskStore): void {
const handlers = this.listeners.get(store);
if (!handlers) {
return;
}
store.off("task:moved", handlers.onTaskMoved);
store.off("task:deleted", handlers.onTaskDeleted);
this.listeners.delete(store);
}
private async handleTaskMoved(store: TaskStore, event: TaskMovedEvent): Promise<void> {
/*
FNXC:WorkflowResolvedColumns 2026-07-31-10:15 (fleet phase — THE SEAM WAS NEVER WIRED):
`decideIssueAction` has taken an injectable `classify` since U12/R2, and the header above states the
defect it fixed: "a user-authored workflow whose terminal column is called something else never closed
its linked GitHub issue". But this — the ONLY production caller — passed no classifier, so every real
move fell through to `legacyColumnLifecycleClass` and the described bug was still live. The seam was
reachable from tests only.
That is the same shape as this branch's earlier finding on the tracking-comment guard: a conversion
that reads as done, with the resolved path unreachable in production. The lesson is that adding the
seam and wiring it are two changes, and only the second one fixes anything.
ORDER MATTERS, and it is inverted from the original. `decideIssueAction` ran FIRST here, before the
tracking-enabled check, because comparing two strings is free. Resolving a workflow is not, so the
cheap property read now short-circuits first and only tracked tasks resolve — the same ordering
`github-tracking-comments.ts` and its GitLab twin settled on. Behaviour is unchanged for untracked
tasks: they returned without acting before and still do.
*/
if (event.task.githubTracking?.enabled !== true) {
return;
}
/*
FNXC:WorkflowResolvedColumns 2026-07-30-14:20 (PR #2754 review — greptile):
EVERY TERMINAL LANE, NOT THE FIRST ONE. `LifecycleColumns` names one column per role by design
(#2721 pinned that), so a workflow declaring `complete` or `archived` on two columns had the second
invisible here: moving a card there left the linked GitHub issue OPEN, and moving it back out never
reopened one.
Core's `resolveTerminalColumns` does not help — it is the same singular pair, one `complete` and one
`archived`. The flag sets are the membership answer.
RESOLUTION FAILURE vs A RESOLVED ABSENCE, the distinction this program keeps paying for (#2731,
#2733, #2734): `ir === undefined` means the workflow could not be READ, and the legacy ids are the
only answer available. A resolved IR that declares no complete lane is an ANSWER — moving a card
somewhere is not "completing" it on a board with no completion lane — so the empty set is used as-is
rather than falling back to `done`.
*/
/*
FNXC:WorkflowResolvedColumns 2026-07-30-10:45 (batch-core — the THIRD state):
The note above draws the right line between "could not read" and "read, and there is no complete
lane". There is a third case that looks exactly like the second and means the opposite, and this
classifier was silently on the wrong side of it.
`synthesizeDefaultColumns` upgrades a v1 graph by emitting every default column with `traits: []`.
Such a board resolves cleanly, so `ir !== undefined`, and every flag set comes back EMPTY — while
its `done` column plainly exists and holds finished cards. Treating that as "this board does not
complete anything" made `decideIssueAction` return null for every transition, so on a v1-upgraded
board GitHub tracking NEVER closed a source issue. And because the source-issue commenter defers to
this service whenever tracking targets the same issue, neither of them posted: the completion
comment disappeared entirely, with nothing logged as an error.
`declaresAnyLifecycleTrait` separates the two. A workflow that expresses no trait on ANY column has
not made a statement about its lifecycle and keeps the legacy vocabulary; a v2 board that declares
traits elsewhere but no complete lane still gets the empty set used as-is, which is the behaviour
the note above argues for and which remains correct.
*/
const ir = await resolveWorkflowIrForTask(store, event.task.id).catch(() => undefined);
const traitsExpressed = ir !== undefined && declaresAnyLifecycleTrait(ir);
const completeLanes = ir === undefined || !traitsExpressed ? undefined : columnsWithFlag(ir, "complete");
const archivedLanes = ir === undefined || !traitsExpressed ? undefined : columnsWithFlag(ir, "archived");
const decision = decideIssueAction(event.from, event.to, (columnId) => ({
complete: completeLanes === undefined ? columnId === "done" : completeLanes.includes(columnId),
archived: archivedLanes === undefined ? columnId === "archived" : archivedLanes.includes(columnId),
}));
if (!decision) {
return;
}
const issue = event.task.githubTracking?.issue;
if (!issue) {
return;
}
const { owner, repo, number } = issue;
if (!owner || !repo || !number) {
await this.safeLogDeletedTaskEntry(
store,
event.task.id,
"Failed to update GitHub tracking issue state",
"Linked issue metadata is incomplete",
);
return;
}
try {
const projectSettings = await store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
await this.safeLogDeletedTaskEntry(store, event.task.id, "Skipped GitHub tracking issue state update", resolution.message);
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
if (decision.action === "close") {
const existing = await client.getIssue(owner, repo, number);
if (existing?.state === "closed") {
await this.safeLogDeletedTaskEntry(store, event.task.id, "Linked GitHub tracking issue already closed", `${owner}/${repo}#${number}`);
return;
}
}
const updateIssueState = async () => {
await client.setIssueState(
owner,
repo,
number,
decision.action === "close" ? "closed" : "open",
decision.stateReason,
);
};
try {
await updateIssueState();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await updateIssueState();
}
await this.safeLogDeletedTaskEntry(
store,
event.task.id,
decision.action === "close"
? "Closed linked GitHub tracking issue"
: "Reopened linked GitHub tracking issue",
`${owner}/${repo}#${number}`,
);
} catch (err) {
await this.safeLogDeletedTaskEntry(
store,
event.task.id,
decision.action === "close"
? "Failed to close GitHub tracking issue"
: "Failed to reopen GitHub tracking issue",
err instanceof Error ? err.message : String(err),
);
}
}
private emitGitHubIssueAction(store: TaskStore, event: GitHubIssueActionEvent): void {
(store as unknown as { emit: (eventName: string, payload: GitHubIssueActionEvent) => void }).emit("github-issue:action", event);
}
private async safeLogDeletedTaskEntry(store: TaskStore, taskId: string, message: string, details: string): Promise<void> {
try {
await store.logEntry(taskId, message, details);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes(`Task ${taskId} not found`)) {
severityAuditLog.warn(`[github-tracking-state] Unable to write log entry for deleted task ${taskId}: ${message}`);
return;
}
throw error;
}
}
private async handleSourceIssueDelete(store: TaskStore, task: Task, meta?: { githubIssueAction?: GithubIssueAction }): Promise<void> {
const sourceIssue = task.sourceIssue;
if (sourceIssue?.provider !== "github") {
return;
}
const [owner, repo, extra] = sourceIssue.repository.split("/");
if (!owner || !repo || extra) {
await this.safeLogDeletedTaskEntry(
store,
task.id,
"Failed to close linked source GitHub issue",
`Invalid source issue repository: ${sourceIssue.repository}`,
);
return;
}
const number = sourceIssue.issueNumber;
if (!Number.isInteger(number) || number <= 0) {
await this.safeLogDeletedTaskEntry(
store,
task.id,
"Failed to close linked source GitHub issue",
`Invalid source issue number: ${String(number)}`,
);
return;
}
const githubIssueAction = meta?.githubIssueAction ?? "auto";
// Source-imported issues represent real incoming work; if no explicit action is provided,
// deleting the task defaults to closing the source issue.
const resolvedAction = githubIssueAction === "auto" ? "close" : githubIssueAction;
if (resolvedAction === "leave") {
await this.safeLogDeletedTaskEntry(store, task.id, "Left linked source GitHub issue unchanged on task delete", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "leave", owner, repo, number, outcome: "skipped" });
return;
}
const projectSettings = await store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
this.emitGitHubIssueAction(store, {
taskId: task.id,
action: resolvedAction === "delete" ? "delete" : "close",
owner,
repo,
number,
outcome: "failed",
error: resolution.message,
});
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
if (resolvedAction === "delete") {
try {
const deleteIssue = async () => {
await client.deleteIssue(owner, repo, number);
};
try {
await deleteIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await deleteIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Deleted linked source GitHub issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to delete linked source GitHub issue", message);
}
return;
}
try {
const existing = await client.getIssue(owner, repo, number);
if (existing?.state === "closed") {
await this.safeLogDeletedTaskEntry(store, task.id, "Linked source GitHub issue already closed", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "skipped" });
return;
}
const closeIssue = async () => {
// Source-imported issues map to completed work, so closure reason is "completed".
await client.setIssueState(owner, repo, number, "closed", "completed");
};
try {
await closeIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await closeIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Closed linked source GitHub issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to close linked source GitHub issue", message);
}
}
private async handleTaskDeleted(store: TaskStore, task: Task, meta?: { githubIssueAction?: GithubIssueAction }): Promise<void> {
if (task.githubTracking?.enabled !== true) {
await this.handleSourceIssueDelete(store, task, meta);
return;
}
const issue = task.githubTracking.issue;
if (!issue) {
return;
}
const { owner, repo, number } = issue;
if (!owner || !repo || !number) {
return;
}
const githubIssueAction = meta?.githubIssueAction ?? "auto";
if (githubIssueAction === "leave") {
await this.safeLogDeletedTaskEntry(store, task.id, "Left linked GitHub tracking issue unchanged on task delete", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "leave", owner, repo, number, outcome: "skipped" });
return;
}
const projectSettings = await store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
const globalSettings = (await store.getGlobalSettingsStore?.()?.getSettings?.() ?? {}) as Pick<GlobalSettings, never>;
const resolution = resolveGithubTrackingAuth({ projectSettings, globalSettings });
if (!resolution.ok) {
this.emitGitHubIssueAction(store, {
taskId: task.id,
action: githubIssueAction === "delete" ? "delete" : "close",
owner,
repo,
number,
outcome: "failed",
error: resolution.message,
});
return;
}
const client = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
if (githubIssueAction === "delete") {
try {
const deleteIssue = async () => {
await client.deleteIssue(owner, repo, number);
};
try {
await deleteIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await deleteIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Deleted linked GitHub tracking issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "delete", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to delete linked GitHub tracking issue", message);
}
return;
}
try {
const existing = await client.getIssue(owner, repo, number);
if (existing?.state === "closed") {
await this.safeLogDeletedTaskEntry(store, task.id, "Linked GitHub tracking issue already closed", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "skipped" });
return;
}
const closeIssue = async () => {
await client.setIssueState(owner, repo, number, "closed", "not_planned");
};
try {
await closeIssue();
} catch (error) {
if (!isTransientGitHubError(error)) {
throw error;
}
await delay(TRANSIENT_RETRY_DELAY_MS);
await closeIssue();
}
await this.safeLogDeletedTaskEntry(store, task.id, "Closed linked GitHub tracking issue", `${owner}/${repo}#${number}`);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "success" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.emitGitHubIssueAction(store, { taskId: task.id, action: "close", owner, repo, number, outcome: "failed", error: message });
await this.safeLogDeletedTaskEntry(store, task.id, "Failed to close linked GitHub tracking issue", message);
}
}
}