diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 6b95e0e77e..e8b0a015b4 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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; diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.github-tracking-renamed-lanes.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.github-tracking-renamed-lanes.test.tsx new file mode 100644 index 0000000000..aa7c78d750 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.github-tracking-renamed-lanes.test.tsx @@ -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( + , + ); +} + +/* +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(); + }); +});