23 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||
|---|---|---|---|---|---|---|
| draft | integrations |
|
2026-08-11 |
|
GitLab MR Status Chip System Design Part 1
Purpose and boundaries
This design preserves the technical source detail for REQ-INTEGRATIONS-GITLAB-MR-STATUS-CHIP-001 during migration.
Requirement mapping
| Requirement | Design section |
|---|---|
REQ-INTEGRATIONS-GITLAB-MR-STATUS-CHIP-001 |
Migrated source detail |
Migrated source detail
Why
A task whose work lives on GitHub shows its pull request's CI, review and automation state as a chip in the chat status bar, so the user sees it without leaving the conversation. A task whose work lives on GitLab shows nothing there. GitLab users have to look up at the topbar button, or open the MR detail panel, to answer "did my pipeline pass". The GitLab merge-request equivalent of that chip does not exist.
What
- A task with at least one open linked GitLab merge request SHALL render an
MRStatusChipin the chat status bar and in the passthrough status row, beside the existing GitHub and Azure DevOps chips. - The chip SHALL show, at a glance: a GitLab merge glyph, a status glyph whose colour matches the MR's existing status colour everywhere else in the product, an MR count when more than one open MR is linked, and auto-fix / auto-merge badges when task MR automation is enabled.
- Hovering the chip on a fine-pointer device SHALL open the existing
MRCIPopoverbody (pass rate, pipeline stage groups, approval row, unresolved discussions, automation controls, merge action, footer). Tapping it on a coarse-pointer device SHALL open that same body inside a bottom-sheet drawer. - The chip SHALL derive its status from the one shared GitLab MR status
derivation, not a new private copy.
getMRStatusColorinapps/web/components/gitlab/mr-task-icon.tsxremains the single source of MR status colour, and its output for every input SHALL be byte-identical before and after this feature. - The chip SHALL NOT own any recurring fetch, polling, cache-warming or
background-sync responsibility. It issues exactly four kinds of request,
all of them existing behaviour it inherits rather than invents:
- the lazy read of the task's MR automation options, on mount;
- the GitLab connection-status read that
useGitLabAvailable()performs on mount, which the chip needs to sourcecanLink. This one has a cross-surface side effect and is stated in full under Sync and freshness; - the MR feedback read that
MRCIPopoveralready owns, only while a disclosure is open (this is what the popover'senabledprop gates; see API surface); - the link and unlink the user explicitly triggers.
Nothing else: no polling, no interval, no warmer, and specifically no
useWorkspaceMRs. See Sync and freshness.
- No file under
apps/web/components/github/changes.PRStatusChip's rendered output SHALL be identical before and after. - All chip copy SHALL go through
t()intoapps/web/src/locales/en/gitlab.json, including accessible labels.
Data model
No new persistent state. The chip reads the existing TaskMR rows
(gitlab_task_mrs, typed at apps/web/lib/types/gitlab.ts) already cached in
the store at taskMRs.byWorkspaceId[workspaceId][taskId], and the existing
TaskMRAutomationOptions cached at taskMRAutomation.byTaskId[taskId].
Fields the chip's own status derivation reads, and only these:
| Field | Type | Used for |
|---|---|---|
id |
string | React key, unlink target, final selection tiebreak |
state |
open | closed | merged | locked | string |
terminal filtering, merged / closed status |
pipeline_state |
"" | success | failure | pending | string |
failed / ready / running status |
approval_state |
"" | approved | pending | string |
ready / awaiting_approval status |
draft |
boolean | draft status |
mr_iid |
number | display, data-mr-iid, primary selection tiebreak |
project_path |
string | secondary selection tiebreak, automation-state lookup |
repository_id |
string? | automation-state lookup |
The chip SHALL NOT read unresolved_discussions for status. That field is
documented as populated only for automation-subscribed MRs, so using it would
make the chip report a different status for two otherwise identical MRs
depending on whether automation happens to be on. The popover body's own live
discussion fetch continues to render the unresolved count, unchanged.
The chip SHALL NOT read merge_status / detailed_merge_status for status.
See Out of scope.
Separately from status, the trigger's data-mr-ready-to-merge attribute and the
popover's merge button both come from the existing isMRReadyToMerge, which
does read unresolved_discussions and detailed_merge_status by design and
carries its own documented rationale. That helper is reused unchanged; the
restriction above applies to chip status only, and the two must not be
reconciled by editing isMRReadyToMerge.
API surface
No new endpoints, no new store actions, no new WS events.
Consumed, all existing:
useTaskMRs(taskId)(apps/web/hooks/domains/gitlab/use-task-mr.ts) for the linked MR list.useGitLabAvailable()for whether "link another merge request" is offered.useTaskMRAutomationOptions(taskId)(apps/web/hooks/domains/gitlab/use-task-mr-automation.ts) for automation flags andmr_states.findMRAutomationStateForMR/autoFixRoundForState(apps/web/lib/gitlab/mr-automation.ts) for round badges.MRCIPopover(apps/web/components/gitlab/mr-ci-popover.tsx) as the popover and drawer body, reused as-is with no new variant and no signature change. Its full prop contract is specified below; every prop it declares gets a named source, because one of them (enabled) decides whether the chip fetches.TaskMRLinkDialog(apps/web/components/gitlab/task-mr-link-dialog.tsx) for the link flow, with its existing{open, onOpenChange, taskId, workspaceId, taskRepositories, repositories}signature unchanged.DELETEtask-MR association viadeleteTaskMR+ theremoveTaskMRstore action, for the unlink control the popover header already renders.
MRCIPopover prop contract
MRCIPopover's declared signature is
{mr, taskId, enabled, canLink, onOpenDetailPanel?, onLink, onUnlink}. The chip
supplies every one of them from a named source:
| Prop | Type | Source |
|---|---|---|
mr |
TaskMR |
the acted-on MR: the live selected MR while the disclosure is closed, the frozen MR while it is open. Always the live store row for that id, never a snapshot. See Selection and ordering. |
taskId |
string |
the chip's own taskId |
enabled |
boolean |
the chip's own disclosure-open state (popoverOpen for the hover variant, drawerOpen for the drawer variant). false whenever the disclosure is closed. |
canLink |
boolean |
useGitLabAvailable() |
onOpenDetailPanel |
(() => void)? |
omitted. See Out of scope; the popover title then renders as static text. |
onLink |
() => void |
opens this chip's own TaskMRLinkDialog. See Link and unlink from the chip. |
onUnlink |
() => void |
a zero-argument closure that calls useUnlinkTaskMR(workspaceId) with the acted-on MR's association id captured from the enclosing render. See Link and unlink from the chip. |
enabled is the load-bearing one. MRCIPopover gates its live MR feedback
read on it (useMRFeedbackGated(mr, enabled)), which is the only reason the
"only network effect" clause in What can be stated at all. MRTopbarButton
passes enabled={popoverOpen} and the chip SHALL do the same for its own
disclosure. Passing a constant true is specifically forbidden: it would make
every mounted chip fetch MR feedback on mount, for every session view with a
linked open MR, and the What clause would be false.
Note the arity: the component declares onUnlink: () => void, not
onUnlink(associationId). The association id is closed over, not passed as an
argument, and which id gets closed over is fixed by the freeze rule rather than
left to the call site.
New exported helpers in apps/web/components/gitlab/mr-task-icon.tsx alongside
getMRStatusColor (the file the repo already designates as the single source of
GitLab MR status):
type MRChipStatus =
| "merged" | "closed" | "failed" | "draft"
| "ready" | "awaiting_approval" | "running" | "neutral"
mrChipStatus(mr: TaskMR): MRChipStatus
selectChipMR(mrs: TaskMR[]): TaskMR | null
aggregateMRChipStatus(mrs: TaskMR[]): MRChipStatus
getMRStatusColor(mr) SHALL be re-expressed as a lookup from
mrChipStatus(mr), so exactly one priority table exists.
aggregateMRChipStatus(mrs) SHALL be defined as the composition
mrChipStatus(selectChipMR(mrs)), returning neutral when selectChipMR
returns null. It is not an independent derivation and SHALL NOT have a tiebreak
rule of its own. This is what makes the trigger internally consistent: the
status the chip reports is, by construction, the status of the MR the chip
identifies and acts on. See Selection and ordering.
One extraction, in apps/web/hooks/domains/gitlab/use-task-mr.ts alongside the
other task-MR hooks:
useUnlinkTaskMR(workspaceId: string): (associationId: string) => Promise<void>
This is the unlink closure that today lives privately inside MRTopbarButton
(deleteTaskMR -> removeTaskMR, error toast using the existing
gitlab:failedToUnlinkMergeRequest and gitlab:theMergeRequestIsStillLinked
keys), moved verbatim. Both MRTopbarButton and the chip SHALL call it, and
MRTopbarButton's observable unlink behaviour SHALL be unchanged. The chip
SHALL NOT re-implement the closure: apps/web/eslint.config.mjs forbids
identical functions and 4+ duplicated strings, so a second copy is a lint
failure, not a style preference.
New testids, forming the chip's observable contract:
| testid | Element |
|---|---|
mr-status-chip |
the trigger button (both single and multi) |
mr-status-chip-drawer |
coarse-pointer drawer content |
mr-status-chip-drawer-close |
drawer close button |
mr-status-auto-fix-chip |
auto-fix round badge |
mr-status-auto-merge-chip |
auto-merge badge |
mr-status-glyph |
the status glyph, carrying data-status |
Trigger attributes, and which MR each one describes. This distinction is load-bearing while the disclosure is open; see Selection and ordering.
| Attribute | Value | Which MR |
|---|---|---|
data-status |
the MRChipStatus |
the live selected MR, always |
data-mr-count |
number of open linked MRs | n/a |
data-mr-iid |
mr_iid |
the acted-on MR |
data-mr-state |
state |
the acted-on MR |
data-mr-ready-to-merge |
"true" / "false" from the existing isMRReadyToMerge |
the acted-on MR |
data-selection-frozen |
"true" while the disclosure is open, "false" otherwise |
n/a |
The acted-on MR is the live selected MR while the disclosure is closed, and
the frozen MR while it is open. data-selection-frozen exists so that the two
regimes are distinguishable from the DOM rather than inferred; without it a test
cannot tell a frozen attribute from a stale one. All three per-MR attributes
switch regime together, so they always describe the same single MR as each
other and as the popover body.
data-mr-ready-to-merge is present on the single and the multi trigger alike,
always describing the acted-on MR. This is a deliberate divergence from
PRStatusChip, whose multi trigger omits the attribute: GitLab's chip always
has exactly one acted-on MR, because it has no multi-MR surface, so there is
always a well-defined MR for the attribute to describe.
Badge attributes. These sit on mr-status-auto-fix-chip, not on the trigger,
and they complete the observable contract — the tables above are the full
attribute inventory and nothing outside them is asserted by an AC:
| Attribute | Element | Value | Which MR |
|---|---|---|---|
data-auto-fix-exhausted |
mr-status-auto-fix-chip |
"true" / "false", from autoFixRoundForState(...).exhausted |
the badge-selected MR |
The badge-selected MR is a third selection, distinct from both the live and
the frozen selection: it is the most attention-worthy auto-fix round across the
open MRs (exhausted beats non-exhausted, then higher current, then the same
mr_iid / project_path / id order — see Selection and ordering). On a
single-MR chip all three coincide. On a multi-MR chip they need not.
State machine
mrChipStatus(mr) is a total function evaluated in this exact priority order,
first match wins. The colour column is what getMRStatusColor returns for that
MR today and MUST continue to return.
| # | Status | Condition | Colour | Glyph |
|---|---|---|---|---|
| 1 | merged |
state === "merged" |
text-purple-500 |
filled merge glyph |
| 2 | closed |
state === "closed" or state === "locked" |
text-muted-foreground |
filled dot |
| 3 | failed |
pipeline_state === "failure" |
text-red-500 |
filled circle-X |
| 4 | draft |
draft === true |
text-muted-foreground |
filled dot |
| 5 | ready |
approval_state === "approved" and pipeline_state === "success" |
text-emerald-400 |
filled circle-check |
| 6 | awaiting_approval |
approval_state === "pending" |
text-sky-400 |
clock |
| 7 | running |
pipeline_state === "pending" |
text-yellow-500 |
spinner, 3s per rotation |
| 8 | neutral |
otherwise | text-muted-foreground |
filled dot |
The ordering is deliberate and load-bearing. Stated exactly, because the neighbouring rows are easy to get backwards:
- A failed pipeline outranks
draft(row 3 before row 4), so a draft MR whose pipeline broke readsfailed, notdraft. readyrequires both an approved review and a succeeded pipeline (row 5).- A pending approval outranks a running pipeline (row 6 before row 7). Row 6
carries no pipeline condition at all: an MR with
approval_state: "pending"andpipeline_state: "pending"isawaiting_approval, notrunning.
This is exactly the order getMRStatusColor already evaluates, row for row.
The refactor SHALL NOT reorder it, and where this prose and the table above
could be read as disagreeing, the table governs.
Rank, and why aggregation has no tiebreak of its own
MR_CHIP_STATUS_RANK orders the statuses by how much attention they deserve:
| Status | Rank |
|---|---|
failed |
5 |
running |
4 |
awaiting_approval |
3 |
ready |
2 |
merged |
0 |
closed |
0 |
draft |
0 |
neutral |
0 |
Four statuses share rank 0, so rank alone does not identify a winner. The
chip therefore never aggregates by rank directly. aggregateMRChipStatus(mrs)
is defined as mrChipStatus(selectChipMR(mrs)) (neutral when selectChipMR
returns null), and selectChipMR breaks every tie by named field. Two
consequences, both intended:
- There is no rank-tie ambiguity to resolve, because ties are resolved on MRs
by
mr_iid/project_path/id, not on statuses. - The trigger's
data-statuscannot disagree with itsdata-mr-iidwhiledata-selection-frozenis"false": the reported status is read off the identified MR. This qualifier is load-bearing and is not a hedge. While a disclosure is open the freeze rule deliberately holdsdata-mr-iidon the frozen MR whiledata-statuskeeps tracking the live selection, so the two may name different MRs for exactly that window. See "Freezing while the disclosure is open", which governs. A test asserting the invariant SHALL scope itself todata-selection-frozen="false".
aggregateMRStatusColor is NOT re-expressed and its body SHALL NOT be
touched. This is a deliberate decision, not an omission:
- Its existing loop (
bestRank = -1, strict>over colour ranks) is first-in-input-order wins on a tie, so[merged, closed]returns purple while[closed, merged]returns muted. That array-order dependence is observable today on the multi-MR kanban badge. - The chip requires the opposite property: a selection that does not depend on
array order. The two cannot share one implementation without either changing
aggregateMRStatusColor's output for all-terminal lists, or importing array ordering into the chip. - Changing that behaviour is re-tuning the GitLab MR colour priority, which this spec already excludes (see Out of scope). Leaving the function byte-identical makes the mandated parity scenario a regression guard rather than a coin flip.
MR_CHIP_STATUS_RANK and the existing colour-keyed STATUS_RANK therefore
remain two separate tables, keyed on different things and serving different
callers. They are not permitted to drift: a test SHALL assert they are
order-equivalent under the status-to-colour map from the table above, i.e.
for any two statuses a and b,
MR_CHIP_STATUS_RANK[a] < MR_CHIP_STATUS_RANK[b] if and only if
STATUS_RANK[colour(a)] < STATUS_RANK[colour(b)]. The single-derivation
invariant this spec protects is the priority table (mrChipStatus, which
getMRStatusColor now reads from); it was never a claim that one rank table
serves both callers.
Selection and ordering
The chip renders one MR inside its popover even when several are linked. That
MR is the selected MR, selectChipMR(mrs), chosen deterministically:
-
Restrict to open MRs (the chip only renders when at least one exists). If none are open, return null.
"Open" means
state === "open"exactly. It is a positive equality test, never "not one of the terminal states".TaskMR.stateis typedopen | closed | merged | locked | string, so the two readings diverge forlockedand for any unrecognised string the backend may emit: under the equality test a locked MR is not selectable and does not count towarddata-mr-count, and an unknown state behaves the same way. This matches the testaggregateMRStatusColorandisMRReadyToMergealready apply, so all three agree on what "open" means. A consequence worth stating: rows 1 and 2 of the status table (merged,closed) are therefore unreachable throughselectChipMR, and remain live only forgetMRStatusColor's other callers and for the rank-parity test. -
Keep those whose
mrChipStatushas the maximumMR_CHIP_STATUS_RANK. -
Tiebreak, in order, by
mr_iidascending, thenproject_pathascending (byte-wise,<), thenidascending.idis the association primary key, so the ordering is total and the selection is unique for any input.
Array position is explicitly NOT the tiebreak. The store's array order comes
from the backend response order and is not a stable, named property. (This rule
governs the chip only. It does not apply to aggregateMRStatusColor, which
keeps its existing array-order behaviour untouched; see State machine.)
Freezing while the disclosure is open
Selection is frozen while the popover or drawer is open. The popover carries destructive and irreversible actions (unlink, merge), and an action target that swaps under the pointer mid-interaction is a hazard the aggregate status is not worth.
What is captured is the association id only, never a copy of the TaskMR
object. The popover body renders the live store row for that id on every
render. So while frozen:
- Which MR the popover acts on does not change.
- That MR's own fields DO stay live: a pipeline that goes from pending to failed
while the popover is open updates the body, and
isMRReadyToMergeand the merge button are evaluated against current data, never a snapshot taken at open. Merging or unlinking on stale fields is the failure this rule exists to prevent, so a value snapshot is specifically forbidden. - The trigger's
data-mr-iid,data-mr-stateanddata-mr-ready-to-mergedescribe the frozen MR, anddata-selection-frozenis"true". - The trigger's
data-statusand glyph continue to track the live selected MR, so a change on a different MR is still visible at a glance. This is the one window in whichdata-statusanddata-mr-iidmay describe different MRs; it is bounded bydata-selection-frozen="true"and is the deliberate cost of not swapping the action target. - The automation badges do NOT freeze.
mr-status-auto-fix-chip(its text and itsdata-auto-fix-exhausted) andmr-status-auto-merge-chipcontinue to track the live badge-selected MR, on the same side of the line asdata-statusand the glyph. The rule that decides this is what each surface is for, not which MR it happens to name. The freeze exists to stop an action target moving under the pointer: unlink and merge are destructive, so the MR they operate on is pinned. The badges are informational — nothing in the popover acts on the badge-selected MR, and no control's meaning changes when the badge changes. A frozen badge would instead hide exactly the event it exists to report: an MR exhausting its auto-fix rounds while the user has the popover open. This means a multi-MR chip may briefly show three different MRs at once —data-mr-iid(frozen, acted-on),data-status(live selection), and the badge (live badge-selection). That is accepted and is bounded bydata-selection-frozen="true". It is not a new hazard: none of the three is an action target except the frozen one, which is the whole point.
If the held id leaves the store, or its row stops being open, the popover or
drawer closes. On close the freeze is released and all attributes return to the
live selection in the same render.
DOM ordering inside both status rows SHALL be: PRStatusChip, then
MRStatusChip, then AzureDevOpsTaskPullRequestChip, then the existing
banners and right-hand controls. This matches the topbar's existing
PRTopbarButton then MRTopbarButton provider order, so a task linked to both
a PR and an MR presents the two providers in the same order in both places.
Both chips render; neither suppresses the other.
How each leg of that ordering is verified differs, and the difference is
deliberate. The PRStatusChip -> MRStatusChip leg has an AC, because both
elements carry testids. The MRStatusChip -> AzureDevOpsTaskPullRequestChip
leg has no AC and is a source-order requirement checked by reading the two
mount points.
The reason is a choice, not an impossibility, and an earlier draft overstated
it. It is true that the Azure chip exposes no data-testid and that adding one
is outside this card's permitted files. It does not follow that the leg is
unobservable: the chip renders a link whose aria-label is a hardcoded,
untranslated `Azure PR ${pullRequestId}: ...`, inside rows that do carry
testids, so a role-and-name locator could reach it without touching any Azure
file. This spec still declines to assert on it, for a different and narrower
reason: that label is an implementation detail of a component this card is
forbidden to modify, so an AC built on it could be broken by an unrelated Azure
change that this card's owner would be unable to fix. Coupling our ACs to
another team's untranslated internal string buys less than it costs. See
Scenarios.
The auto-fix round shown on a multi-MR chip is the most attention-worthy round
across the open MRs: an exhausted round beats a non-exhausted one, then a higher
current wins, and remaining ties resolve by the same
mr_iid / project_path / id order above.