feat(source-panel): GitLab parity for the PR panel and self-hosted GitLab hosts - #466
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Cohesive GitLab-parity fixes plus a deny-by-default, backend-enforced self-hosted allowlist that fails closed and keeps the browser out of host selection. The new config surface is backward-compatible (empty = gitlab.com only), the trust boundary is correctly placed backend-side in [DESIGN-REVIEWED] b24a377 |
Opus 5 Review — ✅ no blocking findingsReviewed No blocking findings. FINDING — src/kiro_crew/dashboard/handlers/source_providers.py:1112 — Verdict recorded via the action's structured output for commit False positive or not applicable? A repository writer can comment: |
Arbiter — ✅ no blocking findingsArbiter found no unresolved long-term items that require action before merging Second-order review for Review detailsI've read both files and the full diff. Here's my assessment. The sub-threshold set is minimal: GPT 5.6 found nothing, the design review passed with no concerns, and Opus 5 raised exactly one finding (explicitly under "no blocking findings") — the Applying the narrow bar: even taking that finding at face value, it is (1) not a one-way door — the fix is a one-line Arbiter-Verdict: PASS No sub-threshold finding meets the long-term-impact bar. Suggested follow-ups (open as issues — non-blocking)
[ARBITER-REVIEWED] b24a377 False positive or not applicable? A repository writer can comment: For a broader accepted-risk deferral, apply |
8e20571 to
9412293
Compare
Round 1 disposition — head
|
9412293 to
8f611bc
Compare
Round 2 disposition — head
|
8f611bc to
fb22818
Compare
Round 3 disposition — head
|
fb22818 to
168623f
Compare
Round 4 disposition — head
|
168623f to
bd3e962
Compare
Round 5 disposition — head
|
bd3e962 to
dba6c23
Compare
Round 6 disposition — head
|
dba6c23 to
861842a
Compare
Round 7 disposition — head
|
861842a to
b9d7629
Compare
Round 8 disposition — head
|
|
Round 3 — rebased onto The only content conflict was in
Resolution — adopt
Gates (local, off-loop CI-parity venv): source-provider suite 240 passed; gitlab/source/dashboard slice 775 passed; isort/flake8/mypy clean (481 files); frontend |
…tLab hosts The Changes panel's GitHub work had three GitLab-only defects in the chip status path, all in code with zero GitLab test coverage, and self-managed GitLab instances were rejected outright. Chip status (_fetch_check_status): - A GitLab MR closed while still a draft reported state 'draft' because GitLab keeps draft=true after close. The tab showed a draft glyph and, since CI is only suppressed for merged/closed, kept polling a dead MR. Draft now only wins while the MR is 'opened', mirroring the GitHub branch's isDraft && state == OPEN. - A skipped or manual-only pipeline mapped to 'running' and spun forever, and disagreed with _gitlab_check's own 'skipped' bucket. Both now share _gitlab_bucket, and _gitlab_pipeline_signal rolls up like GitHub (failure fails, in-flight runs, terminal non-failure passes). - GitLab needed two sequential glab subprocesses per refresh where GitHub needs one. head_pipeline ships with the MR payload, so the pipelines list call is now only a fallback. Panel badge: stateLabel put draft ahead of merged/closed while pullRequestLifecycleState does the opposite, so a closed draft MR read "Draft" in the header and Closed on its tab. Same precedence now. Self-hosted GitLab: github.com and gitlab.com are always accepted; any other host is accepted only when its exact host[:port] is a member of the new deny-by-default dashboard.gitlab_hosts allowlist. The list is config-only (absent from the dashboard-config PUT allowlist), matched exactly with no suffix/wildcard matching and no www stripping, and a portless entry does not authorize an arbitrary port. Malformed entries are dropped at config load rather than sanitized. Every glab spawn pins GITLAB_HOST to the host parse_source_url authorized for that URL and re-checks it against the allowlist before spawn, so a self-managed default in glab config cannot redirect bare API paths and a caller that skipped URL validation is denied instead of reaching an unauthorized instance.
49e36e2 to
b24a377
Compare
|
Rebase round — resolved CONFLICTING against GitHub marked the PR CONFLICTING after
Local gates green on the rebased head: 324 targeted backend tests ( |
…tLab hosts (kirodotdev#466) The Changes panel's GitHub work had three GitLab-only defects in the chip status path, all in code with zero GitLab test coverage, and self-managed GitLab instances were rejected outright. Chip status (_fetch_check_status): - A GitLab MR closed while still a draft reported state 'draft' because GitLab keeps draft=true after close. The tab showed a draft glyph and, since CI is only suppressed for merged/closed, kept polling a dead MR. Draft now only wins while the MR is 'opened', mirroring the GitHub branch's isDraft && state == OPEN. - A skipped or manual-only pipeline mapped to 'running' and spun forever, and disagreed with _gitlab_check's own 'skipped' bucket. Both now share _gitlab_bucket, and _gitlab_pipeline_signal rolls up like GitHub (failure fails, in-flight runs, terminal non-failure passes). - GitLab needed two sequential glab subprocesses per refresh where GitHub needs one. head_pipeline ships with the MR payload, so the pipelines list call is now only a fallback. Panel badge: stateLabel put draft ahead of merged/closed while pullRequestLifecycleState does the opposite, so a closed draft MR read "Draft" in the header and Closed on its tab. Same precedence now. Self-hosted GitLab: github.com and gitlab.com are always accepted; any other host is accepted only when its exact host[:port] is a member of the new deny-by-default dashboard.gitlab_hosts allowlist. The list is config-only (absent from the dashboard-config PUT allowlist), matched exactly with no suffix/wildcard matching and no www stripping, and a portless entry does not authorize an arbitrary port. Malformed entries are dropped at config load rather than sanitized. Every glab spawn pins GITLAB_HOST to the host parse_source_url authorized for that URL and re-checks it against the allowlist before spawn, so a self-managed default in glab config cannot redirect bare API paths and a caller that skipped URL validation is denied instead of reaching an unauthorized instance. Co-authored-by: Kyle Seaman <kseam@dev-dsk-kseam-1b-55230d27.us-east-1.amazon.com>
Problem
The Changes panel's recent PR work was built and verified against GitHub. On GitLab it misbehaves in three ways, and self-managed GitLab is not usable at all:
draft: trueon an MR after it is closed, so the tab renders the draft glyph instead of Closed. Because CI is only suppressed once a source is merged/closed (feat(changes): show PR/MR state on every source tab #406), the CI chip also keeps polling a dead MR every TTL.running, so a fully skipped pipeline never settles — and it contradicted the panel's own Checks tab, which already bucketsskipped/manualas skipped.stateLabelranked draft above merged/closed whilepullRequestLifecycleStatedoes the opposite, so one closed draft MR reads "Draft" in the header and Closed on its tab.parse_source_urlaccepted onlygitlab.com, so anyone on a self-managed instance cannot use the panel at all.Why it matters
GitLab is a first-class provider in this panel (
glabis an allowlisted provider CLI, the merge-state vocabulary is already shared), but the newest surface — the per-tab lifecycle/CI chips — was effectively GitHub-only in its correctness. A stuck spinner and a mislabeled lifecycle are the two signals a reviewer reads at a glance, and the dead-MR polling spends aglabsubprocess per URL per TTL for no result. Self-managed GitLab is the common enterprise deployment; rejecting it makes the whole Changes panel unavailable to those users.Fix (symptoms -> root cause -> change)
Chip status (
_fetch_check_status,src/kiro_crew/dashboard/handlers/source_providers.py):details["draft"]before the state at all, unlike the GitHub branch which requiresisDraft && state == OPEN. Draft is now only reported while the MR isopened;merged/closedwin, and an unknown state (e.g. GitLablocked) still yields nostaterather than a mislabeledopen._gitlab_check's buckets. Both now share_gitlab_bucket, and the new_gitlab_pipeline_signalrolls one pipeline status up exactly the way the GitHub rollup treats check conclusions: any failure fails, anything in flight runs, and a terminal non-failure — including a whollyskippedormanualpipeline — passes. (Status enum verified against the GitLab pipelines REST docs:created,waiting_for_resource,preparing,waiting_for_callback,pending,running,success,failed,canceling,canceled,skipped,manual,scheduled.)head_pipeline(the MR's own HEAD pipeline; the legacypipelinefield is deprecated in favor of it). The chip now reads that and falls back to the/pipelines?per_page=1call only when the field is absent, matching GitHub's one-call-per-refresh cost.Badge precedence (
website/src/components/PullRequestPanel.tsx):stateLabelnow orders merged -> closed -> draft, matchingpullRequestLifecycleState, and is exported so the invariant is unit-testable.Self-hosted GitLab: the reason the old code pinned
GITLAB_HOST=gitlab.comwas that browser input must never choose which instance a credential-bearing CLI talks to. That constraint is kept, and the host is made an operator decision instead of a URL decision:dashboard.gitlab_hosts(list[str], default empty) inDashboardConfig.github.comandgitlab.comare always accepted; any other host is accepted only when its exacthost[:port]is a member of that list.www.is not stripped (unlikegitlab.com), and a portless entry does not authorize an arbitrary port on the same host._coerce_gitlab_hostsfails closed at config load: a non-list yields[], and an entry carrying a scheme, userinfo, path, wildcard, or out-of-range port is dropped rather than sanitized, so a hand-edited config cannot smuggle a different target past the exact-match check.glabinvocation now takeshost=ref.hostand pinsGITLAB_HOSTto the hostparse_source_urlauthorized for that URL._run_jsonre-checks the host against the allowlist before spawn and emits adenied/host_not_allowlistedSEL event otherwise — defense in depth, so a future code path that skips URL validation is denied instead of reaching an unauthorized instance.GET /api/dashboard/configexposesgitlab_hostsread-only (deliberately absent from the PUT allowlist — authorizing an instance is a config-file decision, not a dashboard toggle). The client uses it only to decide which pasted links become source tabs; the backend re-validates every URL regardless.PullRequestLinkIndexrebuilds when the allowlist changes, so adding a host mid-session retro-detects MRs already in the transcript instead of only applying to future messages.Security note (please review this specifically)
Adding a host to
dashboard.gitlab_hostsis an explicit operator decision to let the localglabCLI, with its token, reach that host — including one that only resolves on an internal network. That is intended, operator-consented behavior, which is exactly why the allowlist is deny-by-default, config-only, never browser-supplied, and re-checked at spawn. No loopback/RFC1918 denylist is applied, because an internal-only GitLab is the whole point of the feature; the trust boundary is the operator's config file. This is a different shape from a payload-supplied host (cf. the registry-URL DNS-rebinding discussion on #297), and the threat model is documented indocs/system-specs/modules/security.md.Config/infra signal (flagged by
diff_signals.py):config-baseline.jsonis regenerated for the newdashboard.gitlab_hostsentries only. An unrelated pre-existing drift in the committed baseline (session.empty_response_auto_continue, which is missing from the checked-in file but present in the code) was deliberately stripped to keep this PR scoped; regenerating that key belongs in its own change.Tests
Backend —
test/test_source_providers.py(+14 cases):test_gitlab_chip_status_uses_head_pipeline_without_second_call— locks the one-call path.test_gitlab_chip_status_falls_back_to_pipelines_list— locks the fallback whenhead_pipelineis absent.test_gitlab_chip_state_precedence(parametrized) — draft only whileopened; closed-draft is Closed; merged wins;lockedyields no state.test_gitlab_pipeline_signal_matches_github_rollup(parametrized) —skipped/manualsettle as passed,canceledfails, unknown-but-in-flight runs, empty yields nothing.test_self_hosted_gitlab_rejected_when_allowlist_empty/..._accepted_when_allowlisted— deny-by-default, and the normalized URL keeps the self-managed host so cache keys, the external link, and the CLI pin agree.test_self_hosted_gitlab_matches_host_exactly(parametrized) — portless entry does not authorize a port;evil-gitlab.acme.internal,gitlab.acme.internal.evil.test, a parent-domain entry, andwww.-prefixed all stay rejected.test_self_hosted_gitlab_still_requires_https_and_mr_path— HTTPS-only, no userinfo, MR path still required for an allowlisted host.test_run_json_pins_glab_to_the_allowlisted_host— asserts the childGITLAB_HOST, and that it staysgitlab.comwithout a host.test_run_json_refuses_glab_host_outside_allowlist— spawn-time denial never resolves the executable or calls the sandbox.test_fetch_gitlab_threads_self_hosted_host_through_every_call— everyglabcall in a full fetch carries the host (guards against a future call site forgetting it).Backend —
test/test_config_loader.py(+5 cases,TestGitLabHostAllowlist): default empty; case/trailing-dot normalization; scheme/path/userinfo/wildcard/bad-port entries all dropped;gitlab.comand duplicates dropped; non-list falls back to empty.Frontend —
website/src/test/pullRequestLinks.test.ts(+4 cases): a self-hosted MR is ignored with no allowlist and extracted with one; port and exact-host matrix; and a mid-session allowlist change retro-scans settled messages.website/src/test/PullRequestPanel.test.tsx(+1): badge precedence matches the tab glyph for a closed draft MR.Manual verification
Partly N/A, partly a stated gap. The GitLab code paths are driven through
_run_json/glabsubprocesses, so every behavior change above is covered by unit tests that assert the exact CLI arguments, the child environment, and the normalized output.Not verified: the end-to-end
glabround trip against a real self-managed GitLab server — I have no self-hosted instance reachable from this host. What is proven locally is that the host is parsed, allowlisted, threaded to every call site, and pinned into the child environment; what is unproven isglab apibehaving identically against a self-managed API base. A reviewer with an internal instance adding it todashboard.gitlab_hostsand opening one MR would close that gap.Local gates on this commit:
pytest17,177 passed (only this host's known-environment failures:test_dashboard_originx3, confirmed identical on untouchedmain, plus the known localtest_skillsflat-copy case),isort,flake8,tsc,eslint(237 warnings vs the 1116 CI budget, none new in the changed files),vitest4,502 passed.mypyreports one pre-existingvector_memory.pyfaiss-stub error that reproduces on untouchedmain.Screenshots
N/A — no new or restyled UI. The two frontend changes are behavioral: which label string an existing badge renders for a closed draft MR, and which pasted links become source tabs. Both are asserted by unit tests; there is no new panel, component, or layout to capture.