Files
kandev/docs/specs/gitlab-mr-task-list-badges/spec.md
T
2026-08-21 16:38:21 +01:00

51 KiB

status, created, owner
status created owner
draft 2026-08-12 tbd

GitLab MR Badge on the Sidebar and Tasks-List Rows

Amendment: Rich task title previews extends this draft. It replaces the requirements that MRTaskIcon stay unmodified, add no copy, and have only one hydration owner. The badge placement and provider data-model requirements in this document still apply.

Why

A task whose work lives on GitHub shows a pull-request badge on all three task-row surfaces: the Kanban card, the app sidebar's task list, and the /tasks page rows. A task whose work lives on GitLab shows a merge-request badge on exactly one of them, the Kanban card. On the other two a GitLab-only task looks like a task with no remote contribution at all.

This is not a regression. docs/specs/gitlab-integration/spec.md scoped the MR badge to the Kanban card and to nothing else, and the code matches that scope exactly: MRTaskIcon has one call site, apps/web/components/kanban-card-content.tsx:152. This spec adds the two missing surfaces. It is a sibling of the shipped gitlab-mr-status-chip card and belongs to the same GitLab-parity family.

What

  • A task with at least one linked GitLab merge request SHALL render the existing MRTaskIcon badge on the app sidebar's task row and on the /tasks page's rich task row, in addition to the Kanban card it already renders on.
  • The badge SHALL be the existing MRTaskIcon component, rendered unmodified. No new glyph, no new colour rule, no new status derivation, no new data-testid, and no new user-facing copy are introduced by this feature.
  • On every surface that shows both, the pull-request badge SHALL precede the merge-request badge in DOM order.
  • The /tasks page SHALL become an owner of GitLab MR hydration for the active workspace, because it is the surface whose own row renders the badge. The sidebar SHALL NOT become one; see Hydration ownership.
  • No file under apps/web/components/github/ changes. apps/web/components/gitlab/mr-task-icon.tsx does not change either. PRTaskIcon's and MRTaskIcon's rendered output SHALL be identical before and after this feature.

Data model

No new persistent state, no new API, no new store slice, no new WS action.

The badge reads the TaskMR rows already cached at taskMRs.byWorkspaceId[workspaceId][taskId] (apps/web/lib/state/slices/gitlab/gitlab-slice.ts), typed at apps/web/lib/types/gitlab.ts.

The two provider store shapes are not symmetric, and this asymmetry is the first implementation trap:

GitHub GitLab
store path taskPRs.byTaskId[taskId] taskMRs.byWorkspaceId[workspaceId][taskId]
accessor direct useAppStore selector useTaskMRs(taskId)
workspace scoping none active workspace only

useTaskMRs(taskId) (apps/web/hooks/domains/gitlab/use-task-mr.ts:60) resolves workspaces.activeId itself and returns the module-level, referentially stable EMPTY_MRS when there is nothing to show. A hand-rolled selector that returns a fresh [] causes an infinite re-render loop; the constant's own comment records this.

There is a second GitHub-only source the sidebar row already uses and which has no GitLab counterpart: TaskItem's prInfo prop. It is not prototype-only data. task-session-sidebar-item.ts:109 derives it from the bounded task status projection TaskStatusSummary.pull_request (apps/web/lib/types/task-status-summary.ts), which carries number, state, aggregate_state and url and no merge_request field at all. sidebar-mock-data.ts supplies the same shape for the sidebar prototype. A GitLab analogue of that path is therefore a backend projection change, not a frontend one; see Out of scope.

Surfaces and mount points

Exactly three existing production React files change, plus at most one new sibling component file if the badge-pair extraction reads better out-of-line. Constraints owns that allowance and states where the new file may live; this section is not a second, tighter fence. Line numbers drift; the anchors are the named symbols.

  1. App sidebar row — apps/web/components/task/task-item.tsx, the local TaskPRIcon component and its single call site inside TaskItemContent's title row.
  2. /tasks rich row — apps/web/app/tasks/rich-task-list-row.tsx, PrimaryTaskLine.
  3. /tasks page hydration — apps/web/app/tasks/tasks-page-client.tsx, TasksPageClient.

The Kanban card (kanban-card-content.tsx) is already correct and is the reference shape. It does not change.

The sidebar early-return trap

TaskPRIcon today is:

const hasStorePR = useAppStore((s) => !!taskId && (s.taskPRs.byTaskId[taskId]?.length ?? 0) > 0);
if (hasStorePR) return <PRTaskIcon taskId={taskId!} />;
if (!prInfo) return null;
// ... summary-projection fallback badge

Both returns exit the component. An MRTaskIcon appended after them is unreachable for any task that has a PR, and unreachable for any task that has neither a PR nor prInfo — that is, for the GitLab-only task this feature exists to serve. The component SHALL be restructured so the PR branch and the MR branch are siblings, not alternatives.

taskId is optional on this component (taskId?: string), so hasStorePR is not a usable guard for the MR branch: it is false both when there is no task id and when there is a task id with no PRs. The MR branch needs its own taskId truthiness guard.

Selection and ordering

  • Badge order is fixed and named: pull request, then merge request. It is DOM order within the row's title line, not visual order produced by CSS, and it is identical on all three surfaces. The Kanban card already ships this order (PRTaskIcon then MRTaskIcon), and mr-task-card-badge.spec.ts already asserts it there; the two new surfaces adopt it rather than inventing one.
  • Sidebar title row, full order: title, autopilot icon, pinned icon, PR badge, MR badge, issue badge, agent-error icon, remote-cloud icon, archived chip. The MR badge sits immediately after the PR badge and before IssueTaskIcon: the two contribution badges group together, and the issue badge (a different entity class) follows them.
  • /tasks rich row, full order: state icon, title, PR badge, MR badge, archived badge.
  • Which MR the badge represents, and the multi-MR count, are decided by MRTaskIcon and are unchanged: one MR renders SingleMRIcon, two or more render MultiMRIcon with aggregateMRStatusColor and a count. That includes aggregateMRStatusColor's first-in-array-order tie, which this feature freezes rather than touches (it is re-tuning the colour priority; see Out of scope). There is no per-surface selection rule and this spec SHALL NOT introduce one.

Hydration ownership

useWorkspaceMRs(workspaceId) is the only thing that puts MR rows in the store. Its four existing call sites are kanban-board.tsx (unconditional on workspaceState.activeId), mr-topbar-button.tsx, use-mr-key-to-tasks.ts and use-external-vcs-file-link.ts. Neither the sidebar nor /tasks is among them. Consolidating these behind one provider-level owner is an already-declared out-of-scope card in the sibling spec, and this feature does not take it on.

/tasks becomes an owner. The gated form is forbidden.

TasksPageClient already owns its GitHub hydration, gated on the same setting that gates the badge:

useWorkspacePRs(showTaskDetails ? s.activeWorkspaceId : null);

Mirroring that expression for GitLab is a cross-surface data-loss bug, and this is the second implementation trap. The two hooks do not have the same null behaviour:

  • useWorkspacePRs(null) clears a local ref and touches no store state.
  • useWorkspaceMRs(null) calls resetTaskMRs() with no argument, and resetTaskMRs with no argument assigns taskMRs.byWorkspaceId = {} — every workspace, wiped.

Because AppSidebar is mounted in the root layout (app/layout.tsx) and is therefore on screen on /tasks, the gated form would blank the sidebar's MR badges (and any other MR consumer's data) for the entire time a user sits on /tasks with task details switched off, and would keep re-wiping on every render pass that re-runs the effect. The wipe is not confined to the page that caused it.

The /tasks page SHALL therefore call useWorkspaceMRs(s.activeWorkspaceId) unconditionally, matching kanban-board.tsx's existing unconditional call rather than useWorkspacePRs's gated one. The cost is one GET per workspace per effect run, which is not wasted even when task details are off: the sidebar is mounted on that route and renders MR badges from the same store.

Note "per effect run", not "per mount": the app root renders inside <StrictMode> (apps/web/src/main.tsx), and the hook's cleanup clears fetchedRef, so React's development-only effect replay issues a second GET on first mount. That is existing behaviour shared with all four current call sites, it does not occur in a production build, and no acceptance criterion here asserts a request count. It is stated so nobody writes a call-count assertion against the wrong number.

s.activeWorkspaceId can itself be null, and the unconditional form then passes null and clears the cache. That is correct and is not the case this rule forbids: with no active workspace, useTaskMRs returns EMPTY_MRS for every task and no surface can render an MR badge, so there is nothing for the clear to take away. It is also exactly what kanban-board.tsx already does. The forbidden form is different in kind — it passes null while a workspace is active and other surfaces are displaying that workspace's MRs.

The sidebar does not become an owner

The sidebar SHALL read the store and hydrate nothing, exactly as its PR badge does today. Consequently the sidebar MR badge is visible only when some other mounted surface has hydrated the active workspace's MRs. That is an observation about today's mounts, not a guarantee this spec pins, and the coverage is not uniform across routes — see the two holes named below. No acceptance criterion depends on an ambient hydration path: unit tests seed the store directly, and the E2E scenarios below each name a route with a stated owner (/ via kanban-board.tsx, /tasks via this feature).

This is a deliberate parity choice. The GitHub badge on the sidebar has the same property and the same zero owners; giving GitLab its own fifth call site would make the two providers diverge in the one place a consolidation card has to touch.

Two routes where the sidebar MR badge does not render, and why both are accepted

Both are pre-existing properties of code this feature does not touch. They are named here because a reader would otherwise assume the sidebar badge works everywhere the sidebar does, and because the second one is a live instance of the very pattern the previous section forbids.

Hole 1 — an archived task opened directly. MRTopbarButton is the hydration owner on the task-detail routes, but on desktop it sits inside {!isArchived && (…)} in components/task/task-top-bar.tsx. An archived task opened at /t/:taskId therefore mounts no MR hydration owner, and the sidebar shows no MR badge for any task until the user navigates to a route that has one.

Hole 2 — a task with no GitLab repository, on the task-detail route. useExternalVcsFileLinkHydration (hooks/domains/workspace/use-external-vcs-file-link.ts) calls

useWorkspaceMRs(providers.has("gitlab") ? workspaceId : null);

and components/task/task-page-content.tsx invokes it unconditionally. TaskPageContent is rendered by KanbanTaskShell for /t/:taskId and /tasks/:id. For a task whose linked repositories carry no gitlab provider, that expression passes null while a workspace is active and the sidebar is displaying that workspace's MRs — which is exactly the shape "/tasks becomes an owner" forbids, already shipped, on a sidebar-bearing route. It reaches resetTaskMRs() with no argument and clears taskMRs.byWorkspaceId for every workspace. MRTopbarButton on the same route calls useWorkspaceMRs(workspaceId) and re-fetches, so the two race and the sidebar's MR badges are nondeterministic on that route for a GitHub-only or repository-less task.

Both are accepted for this card, and no acceptance criterion is weakened by them. Three reasons, stated so the decision is not re-litigated at Build:

  1. Every AC that observes a badge is conditioned on the store already holding MRs — AC1 reads "where … the store holds at least one MR", not "wherever the task has an MR upstream". A wipe means the store does not hold them, so AC1 is satisfied vacuously rather than violated. The contract is about rendering what the store has, and that is what the builder must implement.
  2. The fix for Hole 2 is not a fix to this feature's code. It is either scoping useWorkspaceMRs's reset to the workspace it last fetched, or removing the gate at that call site — both changes to a hook four other call sites depend on, including ones that rely on the full clear at sign-out. That is already an explicit Out-of-scope entry below, and this section now names the call site that makes it urgent.
  3. Neither hole is reachable from any scenario in this spec. Every E2E scenario (E1 to E7) runs on / or /tasks, both of which have an unconditional owner and neither of which mounts TaskPageContent. The unit scenarios (U1 to U11) render components directly and mount no route at all.

The builder SHALL NOT attempt to work around either hole from the three production files in scope — in particular, the sidebar SHALL NOT gain its own useWorkspaceMRs call to compensate, which AC7 already forbids.

Nil, empty, and error behaviour

State each of these explicitly rather than by omission:

  • No linked MRs. useTaskMRs returns EMPTY_MRS, MRTaskIcon returns null, and the row renders no MR element. The MR badge SHALL contribute no element of its own to the row in this case: no wrapper <span>, no placeholder, and no conditional spacing that exists only to reserve room for the absent badge. This is the observable form of the requirement, and AC6 is its test. (An earlier draft said "byte-identical layout", which named no baseline and no mechanism; no snapshot baseline is introduced by this feature.)

    This rule bounds what the MR badge adds when there are no MRs. It does not forbid fixed spacing on the badge container. The /tasks badge wrapper gains an unconditional gap class (specified under Responsive and coarse-pointer behaviour) so that two present badges are separated. That class is applied whether or not an MR badge renders, so in the no-MR case it changes nothing and adds no element. AC6 and AC23 are therefore not in tension, and a builder SHALL NOT read AC6 as a reason to leave two badges unseparated. AC23 requires that class unconditionally precisely so that satisfying it can never add an element in the no-MR case that AC6 forbids.

  • No taskId. The sidebar's TaskItem accepts taskId?: string. When it is absent the MR branch renders nothing and SHALL NOT call MRTaskIcon with an empty-string or undefined id.

  • No active workspace (workspaces.activeId === null). useTaskMRs returns EMPTY_MRS; no badge, no error, no fetch.

  • Task outside the active workspace. taskMRs is workspace-keyed, so a row for a task belonging to another workspace renders no MR badge. This is inherited from useTaskMRs and is not corrected here.

  • Corrupted store entry (a non-array value under byWorkspaceId[ws][taskId], e.g. from a partial hydration). MRTaskIcon's existing Array.isArray guard returns null. The new call sites SHALL NOT add a second, differently-shaped guard in front of it.

  • Hydration fetch fails. useWorkspaceMRs swallows the error and clears its fetchedRef so a later workspace switch can retry. No badge, no toast, no error affordance, no retry loop is added by this feature.

  • Workspace switch. useWorkspaceMRs calls resetTaskMRs(workspaceId) for the incoming workspace before fetching, so between the switch and the response the new workspace's rows show no MR badge. That transient blank is existing behaviour and is accepted, not fixed.

  • Task has a PR but no MR, or an MR but no PR. Exactly one badge renders. The row SHALL NOT reserve space for the absent one.

Boundaries and defaults

  • Zero / one / two-or-more MRs. Zero renders nothing. One renders SingleMRIcon with data-mr-count="1" and data-mr-state. Two or more render MultiMRIcon with data-mr-count="<n>", the aggregate colour and a numeric suffix, and no data-mr-state. Inherited from MRTaskIcon; no per-surface cap on the count and no overflow chip is introduced.
  • Archived tasks. The sidebar's archived rows and the /tasks list with "Show archived" on both render through the same components, and the MR badge SHALL behave there exactly as the store-backed PR badge does today: present when the store holds MRs for the task, absent otherwise. Archival is not a suppression condition for either badge. (Note the asymmetry this inherits: the archived sidebar item builder sets prInfo: undefined, so the summary fallback PR badge is suppressed on archived rows while the store-backed badges are not. This feature neither copies nor corrects that.)
  • Terminal MRs. A task whose only MR is merged, closed or locked still shows the badge, coloured by getMRStatusColor. Unlike the status chip, the badge has no open-only filter, and this feature does not add one.
  • Rows with no taskId — taskId is optional on TaskItem (taskId?: string), and AC5 is grounded in that optionality, not in a specific caller. Stated precisely, because it is easy to assume otherwise: every production caller today passes a taskId. TaskItem's only production importer is components/task/task-switcher-row.tsx, whose single mount passes taskId={task.id}. The sidebar's primary-nav and new-task rows render AppSidebarNavItem and AppSidebarNewTaskItem, which are different components and do not render TaskItem at all. So AC5 is a defensive contract on the optional prop rather than a rule about a live surface: it keeps the MR branch from being written in a way that breaks the moment an id-less caller appears, and U4 exercises it synthetically. Do not go looking for id-less rows in the sidebar; there are none, and their absence is not a missed surface.
  • showContributions has no default. It is a required prop on PrimaryTaskLine, passed explicitly true by the rich path and false by the compact path. It SHALL NOT be given a default value.

Concurrency and idempotency

  • Two surfaces, one task, same route. The sidebar and the board (or the /tasks row) both mount a badge for the same task id. Both read the same store entry, so they always agree. Both emit the same data-testid="mr-task-icon-<taskId>"; see Duplicate mounts below.
  • Two hydration owners mounted at once (board plus /tasks is not reachable today, but sidebar-bearing routes each mount one and mr-topbar-button can co-mount with use-external-vcs-file-link). Each useWorkspaceMRs instance keeps its own fetchedRef, so each issues its own GET. setTaskMRs replaces the whole per-workspace map, the request is idempotent, and the outcome converges to the last response regardless of arrival order. No coordination is added.
  • A store write while a row is mounted (setTaskMR from a link, or removeTaskMR from an unlink) re-renders every mounted badge for that task in the same commit. There is no per-surface cache and therefore no staleness window between surfaces.
  • Re-render cost. The badge adds exactly one useTaskMRs subscription per rendered row. Rows for tasks with no MRs SHALL NOT re-render on unrelated taskMRs writes, which is what the stable EMPTY_MRS identity buys. Any selector introduced by this feature SHALL NOT allocate a new array or object.

Responsive and coarse-pointer behaviour

  • MRTaskIcon uses a plain Radix Tooltip on both its single-MR and multi-MR variants. That disclosure behaviour is inherited unchanged: no useTouchDrawer variant, no useHoverPopover wiring, no tap-to-open handling is added on the new surfaces. The badge already ships this exact behaviour on the Kanban card, including on a mobile viewport (e2e/tests/gitlab/mobile-mr-task-card-badge.spec.ts), and this feature changes neither the component nor the trade-off. Upgrading the badge's coarse-pointer disclosure is its own card; see Out of scope.
  • The badge SHALL NOT be gated on any Tailwind width class. It renders at every viewport width on both new surfaces, exactly as the PR badge beside it does.
  • On a mobile viewport the added badge SHALL NOT introduce horizontal document overflow on the /tasks list. Both new badges sit in min-w-0 flex rows whose siblings already truncate.
  • The /tasks row is a click target. The PR badge is already wrapped in a <span> that stops click and pointerdown propagation so hovering or tapping the badge cannot navigate the row. The MR badge SHALL sit inside that same wrapper rather than get a second copy of it, so the two badges cannot drift apart on interaction behaviour.
  • That wrapper carries no className at all today, because it has only ever held a single child, so the row's own gap-2 was the only separation it needed. Two children inside it would render with no separation whatsoever. The wrapper SHALL therefore be given inline-flex items-center gap-1. gap-1 is not a new number: it is what both surfaces that already render badges side by side use — the Kanban card's kanban-card-title-row (flex items-center gap-1 min-w-0, where PRTaskIcon and MRTaskIcon are direct siblings) and the sidebar title row (flex items-center gap-1 min-w-0). Matching it keeps PR-to-MR separation identical on all three surfaces. Two things this deliberately does NOT do: it does not add shrink-0 (both PRTaskIcon and MRTaskIcon already carry shrink-0 on their own roots), and it does not alter the row's existing gap-2 between the title and the badge wrapper. The class is unconditional, so it reserves no space for an absent badge; see Nil, empty, and error behaviour for why that satisfies AC6. AC23 is this rule's test. It is a separate criterion rather than a clause on AC9 because AC9 observes only which elements sit inside the wrapper and in what order, so it cannot fail on an unclassed wrapper.
  • The sidebar row's badges are not wrapped today and SHALL NOT become wrapped: clicking a sidebar row selects the task, which is the desired outcome from anywhere on the row including the badges.

Accessibility and duplicate mounts

  • MRTaskIcon carries no aria-label and this feature adds none. Doing so would be new user-facing copy on a component whose Kanban mount would then disagree with its two new mounts, and the state is already named in the tooltip text (getMRTooltip), not conveyed by colour alone.
  • MRTaskIcon's data-testid is mr-task-icon-<taskId>, and the same id is now emitted by up to two mounted rows for one task on one route (sidebar plus board, or sidebar plus /tasks row). Every Playwright accessor for it therefore SHALL be scoped to a container: the sidebar root (app-sidebar) or the specific row (tasks-list-row, task-card-<id>). No accessor may resolve mr-task-icon-* globally, and none may paper over the duplicate with .first(). The identical hazard already exists for pr-task-icon-* and is recorded in e2e/tests/pr/pr-status-badge.spec.ts.
  • The two badges SHALL remain separate elements with separate tooltips. They are not merged into a combined contribution badge.

Prop naming

PrimaryTaskLine's existing showPullRequest: boolean gates the badge slot on the /tasks row. It is file-local (declared and passed only within rich-task-list-row.tsx) and is passed true from the rich content path and false from the compact path.

One flag SHALL gate both badges, and it SHALL be renamed to showContributions. There is no second flag and no independent per-provider toggle: a user who turns on "Show task details" is asking for the row's remote contributions, not for GitHub's specifically. Leaving a flag named showPullRequest in control of a merge-request badge is the kind of stale name that the next reader has to disprove.

Internationalization

This feature introduces no user-facing string literal. MRTaskIcon renders MR-derived data (getMRTooltip builds from mr_iid, mr_title, state, approval_state, pipeline_state), which the component already documents as non-translatable domain data with the same precedent as GitHub's getPRTooltip.

Consequences, stated so they are not re-derived:

  • apps/web/src/locales/en/*.json SHALL NOT gain a key.
  • apps/web/eslint.i18n.options.mjs SHALL NOT gain an entry. Of the three touched files, two are already on i18nGuardFiles — app/tasks/rich-task-list-row.tsx and app/tasks/tasks-page-client.tsx — so i18next/no-literal-string is a whole-file error on both, not merely a changed-lines ratchet. components/task/task-item.tsx is not on the list, so only the ratchet's changed-lines judgement applies there. This feature adds no literal to any of the three, so the append-in-the-same-PR rule is not triggered on any of them. Adding an unrelated file to the list is a separate change and SHALL NOT be smuggled in here. (components/kanban-card-content.tsx is also on the list, but it is not one of the touched files — it does not change; see Surfaces and mount points.)
  • pnpm run i18n:check and pnpm run i18n:ratchet SHALL pass, and the ratchet's changed-lines judgement SHALL find nothing on the lines this feature touches.

Failure modes

Failure Observable behaviour Contract
GitLab not configured for the workspace listWorkspaceTaskMRs returns an empty map; no badge anywhere No error UI, no banner, no console noise
MR hydration request fails No MR badge; PR badge unaffected Row renders, retry only on a later workspace switch or remount
Store holds a non-array for the task No MR badge MRTaskIcon's existing guard; no crash
Task's only MR is unlinked while the row is mounted Badge disappears in the same commit removeTaskMR filters in place; no stale badge
Task's last MR merges Badge stays, coloured purple (merged) Terminal MRs remain visible on the badge, unlike the status chip
Sidebar mounted on a route with no MR hydration owner No MR badge for a GitLab-only task Stated, accepted, not an AC (see Hydration ownership)
Archived task opened at /t/:taskId (desktop) No MR badge in the sidebar, because MRTopbarButton is inside {!isArchived && …} and no owner mounts Hole 1: stated, accepted, not an AC; pre-existing, no file in scope changes it
Task with no gitlab-provider repository opened at /t/:taskId or /tasks/:id Sidebar MR badges nondeterministic for every task: useExternalVcsFileLinkHydration passes null and wipes byWorkspaceId while MRTopbarButton re-fetches Hole 2: stated, accepted, not an AC. AC1 is conditioned on the store holding MRs, so it is satisfied vacuously. Fix belongs to the useWorkspaceMRs(null) reset-scoping card (Out of scope)

Acceptance criteria

Sidebar row:

  • AC1 — Where a sidebar task row has a taskId and the store holds at least one MR for that task in the active workspace, the row SHALL render MRTaskIcon for that task id.
  • AC2 — Where a sidebar task row's task has both a store PR and at least one MR, the row SHALL render both badges, with the PR badge preceding the MR badge in DOM order.
  • AC3 — Where a sidebar task row's task has an MR and no PR (neither a store PR nor a prInfo summary projection), the row SHALL render the MR badge.
  • AC4 — Where a sidebar task row's task has a prInfo summary projection, no store PR, and at least one MR, the row SHALL render the prInfo fallback PR badge followed by the MR badge.
  • AC5 — When a sidebar task row is rendered without a taskId, the row SHALL render no MR badge and SHALL NOT invoke MRTaskIcon.
  • AC6 — Where a sidebar task row's task has no MRs, the row's rendered output SHALL contain no element whose data-testid starts with mr-task-icon-.
  • AC7 — The sidebar SHALL NOT call useWorkspaceMRs, and no file under apps/web/components/task/ or apps/web/components/app-sidebar/ SHALL import it.

/tasks rows:

  • AC8 — Where the /tasks row is rendered with contributions shown and the store holds at least one MR for the task in the active workspace, the row SHALL render MRTaskIcon for that task id.
  • AC9 — Where the /tasks row is rendered with contributions shown and the task has both a PR and an MR, both badges SHALL render, PR before MR in DOM order, and both SHALL be inside the single propagation-stopping wrapper.
  • AC10 — While the /tasks row is rendered with contributions hidden (the compact path), the row SHALL render neither the PR badge nor the MR badge.
  • AC11 — The single boolean that gates both badges SHALL be named showContributions; no showPullRequest identifier SHALL remain in apps/web/app/tasks/rich-task-list-row.tsx.
  • AC23 — Where the /tasks row is rendered with contributions shown, the single propagation-stopping wrapper holding the badges SHALL carry the classes inline-flex items-center gap-1, and SHALL carry them unconditionally: the classes are present whether or not an MR badge renders, and SHALL NOT be applied only when two badges are present. This pins the wrapper class stated in Responsive and coarse-pointer behaviour, which AC9 alone does not observe: AC9 constrains only which elements sit inside the wrapper and in what order, so an implementation leaving the wrapper unclassed would satisfy every other criterion while shipping two touching badges on /tasks and separated badges on the other two surfaces. The unconditionality is what keeps this compatible with AC6 rather than in tension with it; see Nil, empty, and error behaviour.

/tasks hydration:

  • AC12 — When TasksPageClient renders with an active workspace, it SHALL call useWorkspaceMRs with that workspace id, unconditionally, and SHALL NOT gate the argument on tasksListShowDetails or on any other setting.
  • AC13 — If useWorkspaceMRs is invoked with null while another workspace's MRs are cached, then every workspace's entry under taskMRs.byWorkspaceId SHALL be cleared. This is the existing behaviour AC12 exists to avoid triggering, and SHALL be pinned by a test that seeds two workspaces.
  • AC21 — Where TasksPageClient renders with no active workspace, passing null to useWorkspaceMRs is permitted, and no badge SHALL render on any surface for the duration.

Non-regression:

  • AC14 — No file under apps/web/components/github/ SHALL be modified.
  • AC15 — apps/web/components/gitlab/mr-task-icon.tsx SHALL NOT be modified. The badge is consumed exactly as it ships.
  • AC16 — The Kanban card's rendered badge output SHALL be unchanged, and e2e/tests/gitlab/mr-task-card-badge.spec.ts SHALL pass unmodified.
  • AC17 — No apps/web/src/locales/** file and no eslint.i18n.options.mjs entry SHALL change; pnpm run i18n:check and pnpm run i18n:ratchet SHALL pass.
  • AC18 — No selector or hook added by this feature SHALL return a freshly allocated array or object; MR reads SHALL go through useTaskMRs.

Cross-cutting:

  • AC19 — Every Playwright accessor introduced for mr-task-icon-* SHALL be scoped to app-sidebar, tasks-list-row, or task-card-<id>, and SHALL NOT use .first() to disambiguate duplicates.
  • AC20 — On a mobile viewport, a /tasks row carrying both badges SHALL NOT cause horizontal document overflow.
  • AC22 — Where a sidebar task row's task has at least one MR and the row also renders the issue badge (issueInfo present), the MR badge SHALL precede the issue badge in DOM order. This pins the "PR, MR, issue" grouping stated in Selection and ordering, which AC2 alone does not observe: AC2 constrains only PR-versus-MR, so an implementation appending the MR badge after IssueTaskIcon would satisfy every other criterion while violating the stated row order.

Scenarios

Scenarios are numbered U<n> for unit and E<n> for E2E, in two independent sequences. The two kinds are deliberately not numbered in one run: an earlier draft numbered them continuously, and inserting a unit scenario silently shifted every E2E number and left a stale cross-reference behind. Cite scenarios by these prefixed ids, never by bare number.

Unit (vitest) — U1 to U11

  • U1 — Sidebar, PR and MR together — seed taskPRs.byTaskId["t1"] with one PR and taskMRs.byWorkspaceId["ws1"]["t1"] with one MR, workspaces.activeId = "ws1"; render TaskItem with taskId="t1". Both pr-task-icon-t1 and mr-task-icon-t1 are present, and compareDocumentPosition puts the PR first. (AC2)
  • U2 — Sidebar, MR only — no PR, no prInfo, one MR. mr-task-icon-t1 present, pr-task-icon-t1 absent. This is the case the early return currently makes impossible. (AC3)
  • U3 — Sidebar, prInfo fallback plus MR — no store PR, prInfo={{number: 7, state: "Open"}}, one MR. Both render, PR first. (AC4)
  • U4 — Sidebar, no taskId — render TaskItem without taskId, with MRs seeded for some other id. Assert both of AC5's clauses, because they are not equivalent and the weaker one alone is satisfied by code that violates the AC: (a) no mr-task-icon-* element is present, and (b) MRTaskIcon is not invoked at all, asserted by mocking the module and checking zero calls. Clause (b) is the load-bearing one. An unguarded <MRTaskIcon taskId={taskId!} /> — the same non-null-assertion shape the PR branch beside it already uses — would invoke the component with undefined, hit useTaskMRs' internal guard, render null, and pass clause (a) while breaking AC5 and adding the store subscription AC18 exists to prevent. Clause (b) needs a spy, and no in-repo precedent for one exists — write it fresh. components/kanban-card-content.test.tsx is the precedent for module-level interception only: it replaces the module with a plain stub (MRTaskIcon: () => <span data-testid="mr-task-icon" />), and a stub records no calls, so clause (b) cannot be asserted against it. No test under apps/web/components/ mocks a component module with vi.fn() and then asserts a call count, so there is nothing to copy for the assertion itself. The required shape is a vi.fn()-backed component mock plus a zero-call assertion — mock the module with something of the form vi.mock("@/components/gitlab/mr-task-icon", () => ({ MRTaskIcon: vi.fn(() => null) })), import the mocked symbol, and assert expect(MRTaskIcon).not.toHaveBeenCalled(). Copy kanban-card-content.test.tsx for the vi.mock placement and factory shape; do NOT copy its stub body, which is what makes clause (b) unassertable. (AC5)
  • U5 — Sidebar, no MRs — PR only. No mr-task-icon-* element. (AC6)
  • U6 — /tasks row, contributions shown — seeded PR and MR, showContributions true. Both badges render inside the single wrapper, PR first, and the wrapper carries inline-flex items-center gap-1. (AC8, AC9, AC23)
  • U7 — /tasks row, contributions hidden — same store, compact path. Neither badge renders. (AC10)
  • U8 — useWorkspaceMRs(null) wipes every workspace — seed byWorkspaceId["ws-a"] and byWorkspaceId["ws-b"], render the hook with null, assert both are gone. Add to apps/web/hooks/domains/gitlab/use-task-mr.test.ts if not already covered there. (AC13)
  • U9 — TasksPageClient hydration argument — with tasksListShowDetails false and an active workspace, useWorkspaceMRs is called with the workspace id, not null. (AC12)
  • U10 — Sidebar, MR before the issue badge — one MR seeded and issueInfo={{url: "…", number: 42}} passed. Both mr-task-icon-t1 and the issue badge render, and compareDocumentPosition puts the MR badge first. Run it with a store PR present as well, so the assertion covers the full "PR, MR, issue" order rather than just the adjacent pair. (AC22)
  • U11 — No active workspace, no badge — seed taskMRs.byWorkspaceId["ws1"]["t1"] with one MR but set workspaces.activeId = null; render TaskItem with taskId="t1". No mr-task-icon-* element is present. This observes AC21's second clause, which no other scenario reaches: AC21 is otherwise contract text resting on useTaskMRs returning EMPTY_MRS for a null workspace, with nothing pinning it. (AC21)

E2E (Playwright, required)

apps/web/** is touched and none of the files are on the Testing step's exemption allowlist, so E2E is required. All GitLab scenarios seed through the GitLab mock provider and a task created with no agent, for the reason mr-task-card-badge.spec.ts's seedBoardTask comment records: an auto-started session moves the card out from under the assertion.

Where the seed helpers come from, stated so it is not invented at build time. nextMRIID, seedMR, linkMR, ensureGitLabConfigured and seedBoardTask are file-local and unexported in mr-task-card-badge.spec.ts. Each new spec file SHALL therefore define its own local copies, seeded from that file's versions, and SHALL NOT import them. mr-task-card-badge.spec.ts is not edited to export them, which is what keeps AC16 ("passes unmodified") literally true. Three reasons this is duplication rather than extraction:

  • It is the established in-repo precedent: mobile-mr-task-card-badge.spec.ts already writes its own seedBoardTaskWithMR and imports only GITLAB_HOST and GITLAB_PROJECT from e2e/helpers/gitlab.ts.
  • It does not trip lint, because ESLint rules are per-file. An earlier draft justified this by claiming e2e/** sits in the ignores of the rule block carrying sonarjs/no-identical-functions; that is wrong, and the correct mechanism matters because it is narrower. In apps/web/eslint.config.mjs: the block carrying "sonarjs/no-identical-functions": "warn" has no files and no ignores (it is the global block, so it applies to e2e/** too); the ignores: ["**/*.test.ts", "**/*.test.tsx", "e2e/**"] belongs to the i18n guard block (files: i18nGuardFiles, rule i18next/no-literal-string); and the files: ["e2e/**/*.ts"] override disables react-hooks/*, max-lines, max-lines-per-function and sonarjs/no-duplicate-string — but not sonarjs/no-identical-functions. The rule is therefore live on e2e files. It still cannot fire on this duplication, because ESLint only ever sees one file at a time and so cannot compare a helper in one spec file with its copy in another. Consequence the builder must respect: two identical functions within a single spec file DO warn, and pnpm --filter @kandev/web lint runs eslint --max-warnings 0, so that fails the build. Copy each helper across files freely; do not write two copies of the same helper inside one file.
  • Extracting them into e2e/helpers/gitlab.ts would edit mr-task-card-badge.spec.ts's imports, which AC16 forbids, and consolidating the GitLab e2e seed helpers is a separate cleanup; see Out of scope.

Shared constants are a different matter: GITLAB_HOST and GITLAB_PROJECT SHALL be imported from e2e/helpers/gitlab.ts as the existing specs do, not re-declared.

Where the mock GitHub PR comes from. Three scenarios (E3, E6, E7) need a task carrying a PR and an MR, and the GitHub half has its own seeding path that is not covered by the GitLab helpers above. It SHALL come from the existing mock-GitHub API-client methods, not from a new fixture: apiClient.mockGitHubReset() and apiClient.mockGitHubSetUser() to put the provider in a known state, then apiClient.mockGitHubAssociateTaskPR() to attach a PR to the task.

mockGitHubAddPRs() is the wrong call and SHALL NOT be used for these three scenarios. It is named here only so the mistake is not re-derived: it posts to POST /api/v1/github/mock/prs, which seeds the mock provider's PR catalogue (what pickers and listings read) and creates no task-to-PR association. A scenario that uses it leaves taskPRs.byTaskId[taskId] empty, so PRTaskIcon renders nothing and the PR-before-MR assertion that is the entire point of E3, E6 and E7 can never run. mockGitHubAssociateTaskPR() posts to POST /api/v1/github/mock/task-prs and is the call that populates the association the badge reads. No catalogue call is needed alongside it; the association alone is sufficient.

Its required arguments are task_id, owner, repo, pr_number, pr_url, pr_title, head_branch, base_branch and author_login; state is optional and SHALL be passed explicitly so the seeded PR's state is not left implicit.

The reference is e2e/tests/gitlab/mr-task-card-badge.spec.ts's own test "AC30/AC37: a linked PR and MR both render, PR before MR, and the badge tooltip names a non-open state". It is the exact precedent for all three scenarios: it calls mockGitHubReset(), then mockGitHubSetUser("test-user"), then mockGitHubAssociateTaskPR({ task_id, owner: "testorg", repo: "testrepo", pr_number: 900, pr_url, pr_title: "Companion PR", head_branch: "feat/companion", base_branch: "main", author_login: "test-user", state: "open" }), and asserts PR-before-MR order by compareDocumentPosition on the Kanban card. Reading that file does not modify it, so AC16 stays literally true.

Neither mr-task-card-badge.spec.ts nor e2e/tests/pr/pr-status-badge.spec.ts is edited or imported from — the same local-copy rule as the GitLab helpers applies, and for the same AC16 reason. This is stated because the spec is otherwise precise about GitLab seeding and would otherwise leave three of seven E2E scenarios half-specified.

New file apps/web/e2e/tests/gitlab/mr-sidebar-badge.spec.ts:

  • E1 — Sidebar shows the badge — on / (board mounts the hydration owner), a task with one linked open MR. Scoped to app-sidebar, the row located by [data-task-row-id="<taskId>"], mr-task-icon-<taskId> is visible with data-mr-count="1" and data-mr-state="open". (AC1, AC19)
  • E2 — Sidebar shows no badge without an MR — same route, a task with no linked MR. Inside app-sidebar, mr-task-icon-<taskId> has count 0. (AC6)
  • E3 — Sidebar shows PR before MR — a task with both a mock GitHub PR (seeded per "Where the mock GitHub PR comes from" above) and a linked MR; inside the sidebar row, both badges visible and PR precedes MR by compareDocumentPosition. (AC2)

New file apps/web/e2e/tests/gitlab/mr-tasks-list-badge.spec.ts:

  • E4 — /tasks rich row shows the badge — navigate to /tasks, enable "Show task details" through the display-button menu as task-listing-view-preferences.spec.ts does, locate the tasks-list-row by title, assert mr-task-icon-<taskId> inside that row. (AC8, AC19)
  • E5 — /tasks compact row shows neither badge — same task, details off: inside the row, both pr-task-icon-<taskId> and mr-task-icon-<taskId> have count 0. (AC10)
  • E6 — /tasks row shows PR before MR — task with both (mock GitHub PR seeded as above); inside the row, PR precedes MR. (AC9)

New file apps/web/e2e/tests/gitlab/mobile-mr-tasks-list-badge.spec.ts:

  • E7 — Mobile /tasks row — mobile viewport, details on, task with both badges (mock GitHub PR seeded as above): the MR badge is visible and assertNoDocumentHorizontalOverflow passes. Mirrors mobile-mr-task-card-badge.spec.ts. (AC20)

All seven E2E scenarios (E1 to E7) run on / or /tasks. None mounts TaskPageContent, so none is exposed to either hydration hole named above.

Out of scope

Each is a deliberate exclusion, not an oversight.

  • A merge_request field on TaskStatusSummary, and an MR analogue of the sidebar's prInfo fallback. The summary projection is produced by the backend and carries pull_request only. Adding a GitLab counterpart is a backend contract change (projection, WS delivery, bounded-summary spec) that cannot be tested from apps/web/ alone. Consequence, stated plainly: on a route with no MR hydration owner the sidebar can show a PR badge from the summary while showing no MR badge for the equivalent GitLab task. That gap is this card's, and it is left open on purpose.
  • An MR analogue of TaskItemStatsRow's #<number> text. Same backend dependency, same reason.
  • Giving GitLab MR hydration a single provider-level owner. Already an out-of-scope card on gitlab-mr-status-chip; this feature adds one page-level owner where its own badge needs it and consolidates nothing.
  • Making useWorkspaceMRs(null) scope its reset to the workspace it last fetched. That is the correct long-term fix for the trap AC12 routes around, but it changes a hook four other call sites depend on, including two that rely on the full clear at sign-out. It needs a card that can test all of them. That card also owns Hole 2 above: the already-shipped gated call in hooks/domains/workspace/use-external-vcs-file-link.ts (useWorkspaceMRs(providers.has("gitlab") ? workspaceId : null)), which makes the sidebar MR badge nondeterministic on /t/:taskId and /tasks/:id for a task with no GitLab repository. Two candidate fixes exist — scope the reset, or drop the gate at that call site and let the hook no-op for a workspace with no MRs — and choosing between them requires testing all four call sites, so it is not decided here.
  • Fixing Hole 1 (desktop MRTopbarButton sitting inside {!isArchived && …} in components/task/task-top-bar.tsx, so an archived task's detail route mounts no MR hydration owner). Changing what the archived top bar renders is a task-detail-surface decision with its own parity questions on the GitHub side.
  • Consolidating the GitLab e2e seed helpers (seedMR, linkMR, ensureGitLabConfigured, seedBoardTask) into e2e/helpers/gitlab.ts. It is worth doing, and it would edit mr-task-card-badge.spec.ts, which AC16 forbids here. It is a test-only cleanup card covering every GitLab spec at once, not a rider on this one.
  • Upgrading MRTaskIcon's coarse-pointer disclosure to a drawer or a tap-to-open popover. The Radix tooltip is inherited as-is, and changing it would change the shipped Kanban badge on every viewport.
  • Re-tuning GitLab MR status colours, the multi-MR aggregate, or aggregateMRStatusColor's array-order tie. Frozen verbatim.
  • Merging the PR and MR badges into one combined contribution badge, or collapsing them behind a count. Two providers, two badges.
  • The Azure DevOps row badge. components/azure-devops/ is not touched, and the DOM-order ACs name only PR and MR.
  • Office task surfaces and any other task row that renders no PRTaskIcon today. Only the two surfaces named in Surfaces and mount points gain a badge. (The sidebar's archived rows are not excluded — they render through the same TaskItem and are covered by the boundary rule above.)
  • Any change to which tasks appear in the sidebar or on /tasks, to their ordering, grouping, filtering, or pagination.

Constraints

  • Files SHALL stay at most 600 lines and functions at most 100 lines, both warn with skipBlankLines and skipComments (apps/web/eslint.config.mjs), under eslint --max-warnings 0. task-item.tsx is ~660 raw lines today and already carries one max-lines-per-function disable on TaskItem; the restructure SHALL NOT grow the file's warning count, and extracting the combined badge pair into its own small component is the expected shape.
  • tasks-page-client.tsx has the least headroom of the three, and it is the one file every builder must edit (AC12 adds an import plus a hook call). Measured by stripping blank and comment-only lines, it sits at roughly 597 of the 600 allowed — slightly tighter than task-item.tsx at ~591, despite being the smaller file by raw count (642 vs 660). Treat that figure as approximate: it was derived by hand rather than from an eslint run, so the true count may fall either side of the limit. Two lines of margin is not a margin. The builder SHALL therefore check max-lines on this file explicitly, before assuming a two-line edit is free. If the edit does push it over, the remedy is an extraction out of tasks-page-client.tsx — see the fence below, which permits a sibling under app/tasks/ for exactly this reason. Do not resolve it by adding an eslint-disable: the file carries none today, and silencing a size warning to land a two-line hook call trades a real signal for convenience.
  • Production React changes are confined to components/task/task-item.tsx, app/tasks/rich-task-list-row.tsx and app/tasks/tasks-page-client.tsx, plus at most one new sibling component file — under components/task/ for the badge-pair extraction, or under app/tasks/ if tasks-page-client.tsx needs to shed lines per the bullet above. At most one new file in total, and the builder picks which of the two purposes it serves; if both pressures turn out to be real, that is a genuine second finding and routes back here rather than being absorbed by a second new file. All in-line shapes are permitted too and the choice is the builder's; a new file inherits every constraint the fence carries, in particular AC7 (it SHALL NOT import useWorkspaceMRs) and AC18. The fence exists to stop the change spreading into other surfaces, not to force an extraction into an already-oversized file. Test files, E2E specs and page objects are required edits outside that fence and are not restricted by it.
  • The Playwright page object work SHALL extend e2e/pages/app-sidebar-page.ts with a row-and-badge accessor rather than adding a parallel sidebar page object; sidebar-tasks-page.ts already scopes sidebar-task-item and is the shape to follow.
  • Commands that SHALL pass: cd apps/web && pnpm run typecheck, pnpm --filter @kandev/web lint, cd apps/web && pnpm run i18n:check, the vitest suites for the touched files, and the three new E2E specs.
  • gitlab-integration — the umbrella feature; its badge scope (Kanban card only) is what this card extends.
  • gitlab-mr-status-chip — the sibling parity card; source of the hydration-ownership and colour-freeze exclusions this spec inherits.
  • apps/web/CLAUDE.md, "GitHub PR status UI" — the single-derivation invariant the MR side mirrors.