fix(dashboard): GitHub tracking was unreachable on a renamed board — a defect class the census cannot see (#3149)

The census backlog is verified-exhausted (12 guards, every blocker
re-checked in #3082). This is from the class **the census structurally
cannot count**, and it is a real capability loss.

## The defect

```ts
const GITHUB_TRACKING_EDITABLE_COLUMNS: Set<ColumnId> =
  new Set<ColumnId>(["triage", "todo", "in-progress", "in-review", "ideas"]);

function canTaskEditGithubTracking(column, workflowId) {
  return GITHUB_TRACKING_EDITABLE_COLUMNS.has(column) || workflowId === CODING_IDEAS_WORKFLOW_ID;
}
```

No resolved branch, no flags fallback. On a board whose lanes are
renamed this matched **nothing**, so the helper returned `false` for
every task and `showGithubTrackingSection` hid the section outright.
**The operator could not turn GitHub tracking on or off** — no error, no
explanation, the affordance simply absent. The only thing keeping it
reachable was the unrelated `builtin:coding-ideas` escape hatch on the
right-hand side.

## Why no gate saw it, and why this class matters now

The census counts **comparisons** against legacy ids. This is a **Set
literal — a definition** — consulted with `.has()`. Nothing in the
backlog ever pointed here. It is the same blind spot that hid
`TIME_INDICATOR_COLUMNS` and `BLOCKER_ESCALATION_COLUMNS`, both of which
were also found by hand rather than by any gate.

I found it by scanning for legacy-id **collections that gate a live
column value**, rather than for comparisons: **19 such sites** across
the tree. Most are already correct — either `if (!flags) return
LEGACY_…has(column)` fallbacks, or seed-then-add resolved sets
(`agent-reflection.ts`, `ephemeral-worker-manager.ts`). This one had
neither.

With the comparison backlog at 12 and every remaining entry blocked or
documented, **this is where the remaining renamed-board defects actually
live.**

## The fix

The set's meaning is "not finished" — every lane except complete and
archived — which is what the roles now express:

```ts
if (workflowId === CODING_IDEAS_WORKFLOW_ID) return true;
if (!columnFlags) return GITHUB_TRACKING_EDITABLE_COLUMNS.has(column);   // unchanged pre-fetch
return !isCompleteColumnRole(columnFlags, column) && !isArchivedColumnRole(columnFlags, column);
```

The caller passes `detailColumnFlags` — the **task-identity-guarded**
value. `workflowMoveMetadata` outlives a task switch, and this file's
own `2026-07-30-17:30` note records **six** review findings from
consumers that read around that guard. Passing the unguarded value would
answer about the previous card's workflow: worse than the legacy
fallback, because it is confidently wrong rather than merely stale.

## Verification

| | result |
|---|---|
| suite | **3 passed** |
| mutation (restore the literal) | **1 failed \| 2 passed** — the
renamed-WIP case only |
| dashboard `tsc -p tsconfig.app.json` | **0 errors** |
| census `--strict` | exit 0, **unchanged** — this class is invisible to
it |

The test drives the **production path** (`fetchBoardWorkflows` →
`resolveTaskWorkflowMetadata` → `currentColumnFlags`) rather than
injecting flags as props, so it covers the producer as well as the
consumer. `building` and `shipped` collide with no legacy id, so a
surviving `.has(column)` cannot pass by luck; the `todo` control pins
that the default vocabulary is unaffected, and the renamed-COMPLETE
negative pins that the fix does not hand editability to a finished card.

Note: `tsconfig.test-check.json` fails on `main` as well — pre-existing,
and **zero** of its errors come from this branch's files.

## Suggested follow-up

The remaining 17 collection sites deserve the same pass, and the scan
that found this should probably become a gate — a census that counts
comparisons will keep reporting zero while this class quietly grows. I
have not built that here because the existing gates already need
`#3136`'s attention first, and adding a sixth advisory check that nobody
blocks on would repeat the pattern this session keeps running into.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 10:24:34 -07:00
committed by GitHub
parent d6079970e8
commit 40e64468d2
2 changed files with 163 additions and 3 deletions

View File

@@ -716,8 +716,40 @@ const CODING_IDEAS_WORKFLOW_ID = "builtin:coding-ideas";
FNXC:GitHubTracking 2026-07-22-00:46:
Ideas tasks must be able to opt into or out of GitHub tracking before planning, whether they remain in the Ideas intake column or have advanced in Coding (Ideas). Use the resolved workflow ID rather than its display name so localized names and arbitrary custom workflows cannot gain this editing capability.
*/
function canTaskEditGithubTracking(column: ColumnId, workflowId: string | undefined): boolean {
return GITHUB_TRACKING_EDITABLE_COLUMNS.has(column) || workflowId === CODING_IDEAS_WORKFLOW_ID;
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:59:
THE EDITABLE SET IS A HARDCODED LEGACY LANE LIST, so on a renamed board this capability disappeared.
`GITHUB_TRACKING_EDITABLE_COLUMNS` is `{triage, todo, in-progress, in-review, ideas}` — every lane
except the terminal two. It was consulted with `.has(column)` and had NO resolved branch and NO flags
fallback, so on a board with renamed lanes it matched nothing and `canTaskEditGithubTracking` returned
false for EVERY task. The operator simply could not turn GitHub tracking on or off, with no error and
no explanation; the only thing keeping it reachable was the unrelated `builtin:coding-ideas` escape
hatch on the right.
WHY NO CHECK SAW IT. The census counts COMPARISONS against legacy ids. This is a Set literal — a
DEFINITION — consulted via `.has()`, so nothing in the backlog ever pointed here. Same blind spot that
hid `TIME_INDICATOR_COLUMNS` and `BLOCKER_ESCALATION_COLUMNS`, both of which were also found by hand
rather than by any gate.
The set's meaning is "not finished": every lane except complete and archived. That is what the roles
now express. Flags are OPTIONAL and the legacy set remains the fallback, so a render before the
workflow metadata lands behaves exactly as it does today.
FLAGS MUST BE THE TASK-IDENTITY-GUARDED VALUE. The caller passes `detailColumnFlags`, which is
`undefined` unless `workflowMoveMetadata` describes THIS task — `workflowMoveMetadata` outlives a task
switch, and this file's 2026-07-30-17:30 note records six review findings from consumers that read
around that guard. Passing the unguarded value would answer about the previous card's workflow, which
is worse than the legacy fallback because it is confidently wrong rather than merely stale.
*/
function canTaskEditGithubTracking(
column: ColumnId,
workflowId: string | undefined,
columnFlags: TaskContextMenuColumnFlags | undefined,
): boolean {
if (workflowId === CODING_IDEAS_WORKFLOW_ID) return true;
if (!columnFlags) return GITHUB_TRACKING_EDITABLE_COLUMNS.has(column);
return !isCompleteColumnRole(columnFlags, column) && !isArchivedColumnRole(columnFlags, column);
}
export function TaskDetailContent({
@@ -1931,7 +1963,7 @@ export function TaskDetailContent({
const canEdit = isTaskFieldEditableColumn(task.column, detailColumnFlags) && !isSaving;
/** The card's column name as its own workflow declares it; `undefined` when unresolved. */
const workflowColumnDisplayName = workflowMoveMetadata?.moveColumns?.find((column) => column.id === task.column)?.label;
const canEditGithubTracking = canTaskEditGithubTracking(task.column, taskWorkflowBadge?.id) && !isSaving;
const canEditGithubTracking = canTaskEditGithubTracking(task.column, taskWorkflowBadge?.id, detailColumnFlags) && !isSaving;
const githubTrackingEnabled = githubTrackingEnabledDraft ?? (workingTask.githubTracking?.enabled === true);
const githubTrackedIssue = workingTask.githubTracking?.issue;
const gitlabTrackedItem = workingTask.gitlabTracking?.item;

View File

@@ -0,0 +1,128 @@
/*
FNXC:WorkflowResolvedColumns 2026-07-31-23:59:
GITHUB TRACKING WAS UNREACHABLE ON A RENAMED BOARD.
`canTaskEditGithubTracking` consulted `GITHUB_TRACKING_EDITABLE_COLUMNS` — the hardcoded set
`{triage, todo, in-progress, in-review, ideas}` — with `.has(column)`, and had no resolved branch at
all. On a board whose lanes are renamed it matched nothing, so the helper returned false for EVERY
task and `showGithubTrackingSection` hid the section outright. The operator could not turn tracking on
or off, with no error and no explanation. The only thing keeping it reachable was the unrelated
`builtin:coding-ideas` escape hatch.
WHY NO GATE SAW IT. The census counts COMPARISONS against legacy ids; this is a Set literal — a
DEFINITION — consulted via `.has()`. Nothing in the backlog ever pointed here. Same blind spot that
hid `TIME_INDICATOR_COLUMNS` and `BLOCKER_ESCALATION_COLUMNS`.
THE CASES ARE DIFFERENTIAL. `building` and `shipped` collide with no legacy id, so a surviving
`.has(column)` cannot pass by luck, and the `todo` control pins that the default vocabulary is
unaffected. The board payload is the real one the modal fetches, so the flags travel the production
path (`fetchBoardWorkflows` -> `resolveTaskWorkflowMetadata` -> `currentColumnFlags`) rather than
being injected as props.
*/
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
import * as api from "../../api";
setupTaskDetailModalHooks();
/** A renamed board: no lane id collides with a legacy one. */
const RENAMED_PAYLOAD = {
flagEnabled: true,
defaultWorkflowId: "wf-renamed",
taskWorkflowIds: {},
workflows: [
{
id: "wf-renamed",
name: "Renamed",
columns: [
{ id: "drafting", name: "Drafting", flags: { intake: true, hold: true } },
{ id: "building", name: "Building", flags: { countsTowardWip: true } },
{ id: "shipped", name: "Shipped", flags: { complete: true } },
{ id: "filed", name: "Filed", flags: { archived: true } },
],
},
],
};
/** The default board, as a control — the legacy ids ARE this workflow's ids. */
const DEFAULT_PAYLOAD = {
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
taskWorkflowIds: {},
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [
{ id: "todo", name: "Todo", flags: { intake: true, hold: true } },
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
},
],
};
function renderIn(column: string, payload: unknown) {
vi.spyOn(api, "fetchBoardWorkflows").mockResolvedValue(payload as never);
return render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={vi.fn()}
addToast={noop}
/>,
);
}
/*
The section is the observable: `showGithubTrackingSection` is `canEditGithubTracking && ...` for a
task with tracking off, so "can the operator edit this?" and "is the section on screen?" are the same
question here — which is exactly the user-facing symptom.
*/
const trackingSection = () => screen.queryByText("GitHub tracking");
describe("GitHub tracking editability under a renamed board vocabulary", () => {
/* Control: the default vocabulary offers the section. Passes before and after the fix, so a
generally broken modal cannot hide behind the renamed case below. */
it("default vocabulary: a task in `todo` can edit GitHub tracking", async () => {
renderIn("todo", DEFAULT_PAYLOAD);
await waitFor(() => expect(trackingSection()).toBeInTheDocument());
});
/* The defect: `building` is in no legacy set, so the section vanished entirely. */
it("renamed vocabulary: a task in the WIP lane can edit GitHub tracking", async () => {
renderIn("building", RENAMED_PAYLOAD);
await waitFor(() => expect(trackingSection()).toBeInTheDocument());
});
/*
The paired negative: resolving roles must not hand editability to a FINISHED card. The legacy set
excluded `done`/`archived` and the resolved form must exclude their renamed equivalents, or the fix
trades a missing affordance for one that should not be there.
*/
it("renamed vocabulary: a task in the COMPLETE lane cannot edit GitHub tracking", async () => {
renderIn("shipped", RENAMED_PAYLOAD);
/* Wait for the workflow fetch to land before asserting absence, or this passes vacuously on the
pre-resolution render — where the flags are undefined and the legacy fallback also says no. */
await waitFor(() => expect(screen.getByText("Renamed")).toBeInTheDocument());
expect(trackingSection()).not.toBeInTheDocument();
});
});