Issue Radar Module
September 6, 2026 · View on GitHub
Overview
Issue Radar is an opt-in (defaultEnabled: false) built-in app for tracked-item
and change-request triage across THREE providers — GitHub, GitLab and Azure
DevOps (see Providers). It connects one or more repos via the
user's own vendor CLI session (gh, glab or az — no OAuth app, no PAT held
by Kiro Crew) and provides a 3-column workbench: browse/filter issues (work
items on Azure DevOps), view AI-summarized detail + timeline, apply triage
actions (label, close/reopen), and record per-issue investigation findings
in a local ledger. A parallel PULL REQUESTS section reuses the same shape —
filter by lifecycle (open / merged / closed-unmerged), person, draft and label;
read an AI summary of the description plus the whole review conversation; see
the automated checks ("auto review") on the head commit; and ACT on a PR without
leaving for the provider's web UI — approve / request changes, comment, close or
reopen, merge or arm the provider's own auto-merge, and cancel or re-run CI, per-PR
or in bulk across a selection (see Pull-Request Actions). A background watcher
optionally notifies on new issues.
Providers
Three providers are supported, and each is a plain MODULE that mirrors the others
function-for-function (github_client, gitlab_client, azure_client).
provider.py owns the identity type (RepoKey), the dispatch table and the
display vocabulary; ProviderClient is the protocol the routes require, and
because a module cannot be statically checked against a Protocol, conformance is
enforced by TestClientParity, which compares every member's signature against
GitHub's — GitHub is the REFERENCE rather than one peer among three, since the
routes, caches and components were all written to its names, so a disagreement is
always the other client drifting and the failure names which one. That class also
asserts its own table covers every entry in provider.PROVIDERS, so a fourth
provider cannot be registered while the gate silently keeps comparing three.
Each client module is the stable composition façade for its provider. The
routes and tests continue to import github_client, gitlab_client and
azure_client; those modules retain the protocol surface, exception aliases,
constants and patchable I/O chokepoints. Provider-specific sibling modules keep
the implementation boundaries explicit: *_transport.py owns URL, environment,
request and pagination mechanics; *_normalization.py converts provider payloads
into the shared GitHub-shaped records; and github_queries.py owns GitHub's
GraphQL, dependency and search query plans. The façades inject their current
bindings into those helpers so established monkeypatch seams remain authoritative.
The reviewed real glab and az process spawns remain in
gitlab_client._glab_run and azure_client._az_run, respectively, which keeps
the spawn-audit allowlist tied to the same security chokepoints.
| GitHub | GitLab | Azure DevOps | |
|---|---|---|---|
| Provider id | github | gitlab | azure |
| CLI that owns the credential | gh api | glab api | az devops invoke |
| Host | github.com (pinned) | gitlab.com, or an allowlisted self-managed host[:port] | dev.azure.com (pinned) |
RepoKey.owner carries | owner | group path (group/subgroup) | {organization}/{project} |
| Tracked item | issue | issue | work item (project-scoped) |
| Change request | pull request, # | merge request, ! | pull request, ! |
| Review verbs | approve / request changes / comment | approve / comment (request changes REFUSED) | comment only (both verdicts REFUSED — see below) |
| Merge methods | MERGE / SQUASH / REBASE | MERGE / SQUASH (REBASE refused) | MERGE / SQUASH / REBASE |
| Auto-merge | enablePullRequestAutoMerge | REFUSED (see below) | autoCompleteSetBy |
| Assignees | a set, capped at 10 | a set (Free keeps only the first) | exactly ONE (System.AssignedTo); more than one is REFUSED |
Every provider is reached by shelling out to its vendor CLI as a raw REST
passthrough, and Kiro Crew stores NO credential of its own. gh api, glab api
and az devops invoke are each the vendor's own generic REST verb, so the client
modules speak the provider's REST API directly while the CLI owns the token — an
authenticated gh/glab session, an az login session, or AZURE_DEVOPS_EXT_PAT.
This is a deliberate security posture rather than an implementation shortcut: there
is no OAuth app, no hosted backend, no PAT in config.json, and no token in the
gateway process, so the app's blast radius is whatever the user's own terminal
already had. Each client runs its CLI with a list argv (never shell=True) and a
minimal environment, so there is no shell-injection surface either. The cost of the
posture is that a provider install the vendor CLI cannot reach is not supported at
all — see "On-premises is out of scope" below.
Identity is three-level on Azure DevOps, using the overloading GitLab already
needs. A GitHub repo is (owner, repo); the other two add provider and host,
and RepoKey.owner carries the whole namespace ABOVE the repository however deeply
nested it is. On GitLab that is the group path; on Azure DevOps it is
{organization}/{project}, with repo the git repository inside that project. The
field is not split further because both providers treat the whole path as the
project's address, so one field keeps the storage layout, the connected-repo gate
and all ~38 route signatures unchanged. Azure's web address inserts a literal
_git between project and repository (RepoKey.web_url), because a project holds
repositories alongside boards, pipelines and artifacts and that segment is what
disambiguates them.
The Azure host is PINNED and the legacy URL form is canonicalized.
normalize_host replaces whatever a client sent with dev.azure.com for Azure
(and github.com for GitHub), because the host becomes part of a cache path and
part of the identity a repo is looked up by, so it must not be request-influenced.
The legacy {organization}.visualstudio.com form is still accepted when PARSING a
pasted URL — matched as a host SUFFIX on the parsed hostname, never as a substring
of the URL — and canonicalized to dev.azure.com there, so one organization cannot
acquire two identities and two cache trees by being connected from two URL shapes.
The significant semantic limitation: Azure work items are PROJECT-scoped and
carry no repository dimension at all. There is no "issues in this repository"
question to ask Azure DevOps, so list_open_issues ignores repo and returns the
PROJECT's work items — which means two repositories connected from one project
legitimately show the same tracked-item list. That is not a bug and not a cache
collision; it is the shape of the platform, and the UI says so rather than implying
a per-repo list. Pull requests have no such problem: they are repository-scoped
like everywhere else. Two consequences follow:
- Listing is always two calls. Azure has no filtered work-item list endpoint,
so every listing is a WIQL query that returns ids (
_WIQL_TOP) followed by a batched hydrate of those ids (_BATCH_MAX_IDSper call, chunked and stitched). Both legs run under the paginating timeout budget and the result is cached, so the cost is paid per refresh rather than per view. - No state vocabulary is assumed. A work item's
System.Statevalues belong to the project's PROCESS TEMPLATE (Agile, Basic, Scrum, CMMI), where "Closed", "Done", "Completed" and "Resolved" are all real closing states. Nothing hard-codes a state name: the closing states are read from the work item type's own state definitions and the open filter is built from that.
Azure DevOps is READ-ONLY through this app's own controls. get_repo_permissions
reports push/triage as false, and routes._repo_can_write consults that before
admitting any mutation, so labelling, commenting, state changes, merging and
auto-merge all refuse for an Azure repo even though the client implements them.
This is not an oversight, and the reason is worth recording. The only authorization
signal reachable through the az devops invoke passthrough is project TEAM
MEMBERSHIP, and membership does not imply repository write: Azure's permissions are
per-repository ACLs a project can override, so a team member can have Git
"Contribute" denied while still editing work items, or the reverse. Inferring
push from membership therefore over-grants — it offers controls the repository
will refuse — and it does so through a gate whose answer is cached. Azure exposes no
effective-permission read this transport can address (only namespace ACL bitmasks).
A user who holds the rights still acts in Azure DevOps directly, where the
permission is evaluated against their real identity.
The write implementations are kept, tested and refused at the gate rather than deleted, so restoring them needs only an authorization source worth trusting.
Azure DevOps reaches GitHub's parity on merging, but NOT on review verdicts.
It expresses all three merge strategies
(noFastForward / squash / rebase — REBASE maps to the linear rebase, never
to rebaseMerge, because the caller asking for a rebase means the linear history),
and a genuine reversible auto-merge: patching autoCompleteSetBy to an identity
arms the PR and patching it to the empty GUID disarms it, which is a real armed
STATE rather than a modifier on the merge call.
Review verdicts are the exception, and the reason is worth recording because it is
not a gap in the API surface but in what the API can promise. Azure's reviewer
vote attaches to the PULL REQUEST, not to a commit: there is no revision parameter
to send. So between the route's head-moved check and the vote, a push can land and
the verdict then records against code nobody read. Azure resetting votes on push
does not close that ordering — the reset fires with the push, before the vote
arrives — and no compensating fix works either, since withdrawing a vote after
re-reading the head is itself a write whose failure would leave an approval
standing on unreviewed code. PR_REVIEW_EVENTS is therefore ("COMMENT",) and
APPROVE / REQUEST_CHANGES are refused with an explanation, which is the same
choice GitLab makes for the verdict it cannot express. A human can still vote in
Azure DevOps' own UI, where they see the head they are voting on.
Azure's
mergeable_state resolves to mergeable (the value _MERGE_ALLOWED_STATES
admits) only when every BLOCKING policy evaluation is approved, so unlike GitLab's
legacy can_be_merged it is a real "protections satisfied" reading rather than
"no conflicts"; a policy read that fails returns unknown, because a gate that
cannot read the policies must not claim they are satisfied.
Labels are TWO unrelated systems on Azure DevOps. A pull request's labels are
tag definitions attached to the PR. A work item's are System.Tags, a single
delimited STRING field on the item — so every mutation is a read-modify-write of
the whole field (Azure has no add-one-tag operation), tag names are case
SENSITIVE, and a name containing , or ; is rejected because the delimiter
cannot survive the round trip. Azure tags carry neither colour nor description, so
the colour every tag renders in is SYNTHETIC (_SYNTHETIC_LABEL_COLOR, the same
neutral fallback the other two clients use for an uncoloured label) and
create_label returns that synthetic colour rather than the caller's — the caller
is not told a colour was saved when none was.
On-premises is out of scope, for both providers that have such an edition, and
for the same reason. GitHub Enterprise Server and Azure DevOps Server are both
unsupported because neither has a credential path this app's transport can use. For
Azure the reason is concrete: the azure-devops CLI extension does not support
Azure DevOps Server at all, so a CLI-passthrough client cannot serve it. Supporting
it would mean raw REST plus a credential Kiro Crew stores itself, which is exactly
the posture stated above. Both providers are therefore listed in
_PINNED_HOSTS, and azure_client._resolve_host refuses every host but
dev.azure.com — including an EMPTY one, rather than defaulting, so a call site
that forgot the host fails loudly instead of silently targeting a host the caller
never named.
No provider's CLI is a hard dependency. app.json lists gh, glab and az
under dependencies.optionalCommands, so a shop that uses one provider installs
the app without the other two CLIs on the host. A missing CLI surfaces as a setup
error on the first call to THAT provider, not as an install-time refusal.
Routes
All routes live under /api/apps/issue-radar/ and are registered by
apps/builtins/issue_radar/backend/routes.py:register_routes. Every handler is
wrapped in _require_enabled (returns 403 when the app is disabled).
| Method | Path | Purpose |
|---|---|---|
| POST | /connect | Connect a repo (validates URL, verifies gh access) |
| GET | /issues | List open/closed issues (cached, paginated). poll=1 takes the probe-gated path — see Client-Side List Polling. first_page=1 (open only) takes the progressive first-paint fast path — see First Paint |
| GET | /issue | Full issue detail + timeline |
| GET | /ref | Compact summary of one referenced issue/PR (hover preview + issue-vs-PR resolution). One gh call, no timeline, short-TTL cache |
| GET | /labels | Repo label set (cached with a 10-min TTL; see "The label cache expires") |
| GET | /members | Repo collaborators (authoritative API or fallback) |
| GET | /repos | Connected repos list. Rows missing a cached permissions object (connected before permissions were tracked) are self-healed with a live verify_repo_access, run CONCURRENTLY under a bounded semaphore (_REPO_HEAL_CONCURRENCY) rather than one-at-a-time, since this gates app open; a single unreadable repo is skipped, not fatal |
| GET | /recent-repos | Repos the gh user contributed to recently (connect-dialog picker) |
| DELETE | /repos | Disconnect a repo (drops config + cache) |
| GET | /me | Current gh login |
| GET/PUT | /settings | Per-repo triage settings. The PUT replaces the whole document, so it carries the revision it read and is refused with 409 if the stored revision has moved — otherwise a stale tab would erase a label appended meanwhile |
| POST | /settings/role | APPEND one label to a triage-label role, under the config lock. Exists because the PUT replaces the whole document, so a client read-modify-write only serializes itself — two dashboard tabs would each read the same settings and the later full replacement would drop the other's label |
| GET | /issue-ai | AI summary + suggested labels (kirocrew-lite) |
| GET | /pulls | List open/closed PRs (cached, poll=1 probe-gated as for /issues; first_page=1 (open only) takes the progressive first-paint fast path — see First Paint; rows enriched with diff size + check tally via one GraphQL call and merge readiness via a second, lean one run CONCURRENTLY, each topped up by number for rows outside its first:100 window). Rows whose enrichment failed carry null (unknown, not zero) and are deliberately NOT written to the cache, so the next read retries |
| GET | /pulls/search | PRs matching a per-person filter, resolved server-side by GitHub search (escapes the list's page cap). Paginates only as far as its own cap and reports truncated so the UI says "newest N" rather than implying completeness |
| GET | /pull | Full PR detail + conversation (issue timeline merged with inline review comments) + automated checks on the head commit. Cache-first with a short server-side TTL (PR_DETAIL_CACHE_TTL_SEC), so a plain GET self-refreshes and no caller has to pass refresh=1 to stay current |
| GET | /pull-ai | AI summary of a PR (description + whole conversation + check state), cached against a fingerprint that hashes the conversation's CONTENT — so an edited comment invalidates it, not just a new one. The configured dashboard language (when set) is folded into the fingerprint too, so switching languages earns a fresh summary instead of serving the cached one in the old language |
| POST | /labels/apply | Apply label changes (add/remove) |
| POST | /issue/state | Close/reopen an issue |
| GET/PUT | /investigation | Per-issue investigation record. The PUT is the ONE app route also reachable with the gateway internal secret (_MIXED_INTERNAL_API_PATHS), because it is the write behind the issue_radar_record_investigation MCP tool — see Recording findings |
| GET/POST | /recommendations | AI label taxonomy recommendations |
| POST | /labels/create | Create a new repo label |
| GET | /tagging | The untagged queue (also serves bulk_max, the bulk-apply cap, so the client chunks on the server's real limit; and titles bounded to the slice a recommendation's examples can cite) (open issues with ZERO labels) plus any cached per-issue label suggestions for it. Never runs the model, so opening the Tagging dashboard costs nothing; suggestions for issues that have since been labelled elsewhere are filtered out |
| POST | /tagging | Generate per-issue label suggestions with ONE batched model call (_TAG_BATCH_MAX = 50 issues). Without numbers it takes the next un-analysed slice, so repeated calls walk a long backlog without re-paying; with numbers it re-analyses specific issues. Proposals are intersected with the repo's real label set AND with the batch that was shown, so injected issue text can neither invent a label nor reach an issue outside the batch |
| POST | /labels/apply-bulk | Apply label ADDITIONS to many issues at once (add-only — removal stays a per-issue action). Unknown labels are rejected before any write, so a typo cannot half-apply the batch; per-issue failures are reported rather than swallowed, and only the issues that actually got labelled leave the queue |
| POST | /pull/state | Close or reopen a PR. Routed through the provider's PULL endpoint, not the issue endpoint — a merged PR's un-reopenability then comes from the provider instead of silently succeeding against the issue shadow |
| POST | /pull/review | Submit a review (approve / request_changes / comment). Requires head_sha — a review is a verdict on a REVISION, so it rides as GitHub's commit_id / GitLab's sha and a force-push between render and click is refused rather than recorded. A body is required for the latter two (the provider rejects them bodyless). GitLab has no "request changes" verb, and Azure DevOps can bind NEITHER verdict to a revision, so each client REFUSES rather than degrading a verdict to a comment |
| POST | /pull/comment | Post a conversation comment on a PR |
| POST | /pull/merge | Merge a PR now. Per-PR only — never bulk. Requires head_sha, sent as the provider's sha precondition so the merge is pinned to the reviewed commit. Cannot bypass a gate: the provider enforces branch protection on its own endpoint, and a 405 refusal is mapped to a readable message |
| POST | /pull/auto-merge | Arm or disarm the PROVIDER's own auto-merge, for a PR that is not mergeable yet. GitHub and Azure DevOps only — REFUSED on GitLab, where merge_when_pipeline_succeeds is a deferral modifier on the merge endpoint rather than an arm verb (see "GitLab auto-merge is REFUSED outright" below); the UI hides both controls there. Callers must send only rows that are not landable yet — the provider refuses an already-clean or already-merged PR, and the list row carries mergeable_state so the bulk bar can tell (see "Merge readiness is on the LIST row") |
| GET | /pull/runs | The CI runs on a PR's head commit, each with its id plus server-computed cancellable/rerunnable, so the UI never offers an action the provider will refuse |
| POST | /pull/run | Cancel or re-run one CI run (failed_only re-runs just the failed jobs) |
| POST | /pulls/bulk | Apply ONE action to many PRs (_BULK_PR_ACTIONS: close, reopen, approve, comment, auto_merge, cancel_auto_merge; max _BULK_PR_MAX = 50). approve additionally requires a head_shas map keyed by PR number, covering EVERY number in the request (see rule 2). Sequential, because the PRs share one provider rate limit. Partial failure is reported per PR rather than failing the batch |
Recording findings
The Investigate / Review buttons open a KiroCrew chat session seeded with a triage prompt. When the agent concludes it writes its verdict back into the item's investigation record — that is what puts a verdict + summary on the issue's card instead of leaving it in chat scrollback.
That write goes through the issue_radar_record_investigation MCP tool, not
a raw HTTP call. An agent session holds no dashboard credential:
- the access cookie is
httpOnly, so the frontend cannot hand it to the agent; KIROCREW_INTERNAL_SECRETis stripped from agent env bysandbox._AGENT_DENIED_ENV_KEYS;.local_secret— needed for theGET /api/token/localbootstrap — is on thesecurity.pysensitive-path denylist, for tool reads and for the shell forms.
So a direct PUT /api/apps/issue-radar/investigation from the agent is refused
with 403 {"error": "Token required"}. It used to be exactly what the seed
prompt asked for, which meant no investigation ever recorded findings and the
card's verdict/summary render path was unreachable. The tool runs in the
kirocrew-core MCP server, which holds the internal secret legitimately, so the
route is listed in _MIXED_INTERNAL_API_PATHS — the full path only, never the
/api/apps/issue-radar prefix, which would also admit the forge-write routes
(/labels/apply, /issue/state) to any internal-secret holder.
The tool takes the findings as flat args (verdict, root_cause,
suggested_labels, next_action, summary) rather than a nested object:
FieldSpec validates scalars and string lists, so a findings dict would reach
the gateway unvalidated. Empty fields are dropped, because the store merges
findings per key (store._merge_findings) and reads an empty value as "leave
this alone" — so a patch carrying only a verdict keeps the root_cause,
summary and labels an earlier write stored. An explicit null clears the whole
findings object (the UI's clear path); there is deliberately no per-field clear.
provider/host/kind are always sent explicitly — the record is keyed on them,
and defaulting them records a GitLab item into a same-slug GitHub repo's ledger.
Per-key merging is the contract WITHIN one run and the wrong one ACROSS runs. A
re-run ("Start over", or any path that opens a replacement session) recording a
verdict and summary but no root_cause would inherit the previous run's
root_cause, leaving the record — the only copy — holding a verdict assembled
from two investigations with nothing marking which parts came from which. The
store therefore owns the run boundary, because only there is it atomic with the
write: the record remembers the session its findings were written under
(findings_slot_key), and the FIRST findings write under a different session
REPLACES rather than merges, while later writes from that same session keep
merging. The prior verdict survives until a new one exists and never blends with
it — which is why the replacement is not done by clearing at session open
(agentSession.ts says so at the save site): the record is the only copy, so an
abandoned re-run would lose the prior verdict permanently. A boundary needs BOTH
sessions known and different; an unknown owner (findings recorded for an item
with no session linked) or a cleared link falls back to merging, which is the
non-destructive reading.
In the MCP tool, every finding string and label goes through the platform
redaction shim (platform.redact_via_context → exfil URLs + credentials) before the
PUT: findings are LLM prose about an untrusted issue body, they are stored verbatim,
and the card re-renders them on every visit, so a credential quoted into a
root_cause would otherwise be persisted and redisplayed. This is a tool-level
guarantee, not a route-level one — the route itself does not redact, because its
other caller is the cookie-authed frontend writing the session link, not model
output.
Storage Schema
All data under app_data_dir("issue-radar") (typically ~/.kiro/crew/apps/issue-radar/data/):
config.json # Connected repos, per-repo settings
repos/<owner>/<repo>/
issues-cache.json # Open issues (schema-versioned, + poll probe)
issues-closed-cache.json # Closed issues (capped at 100)
labels-cache.json # Repo label definitions (10-min TTL, + fetched_at)
members-cache.json # Collaborators roster + source
issue-<N>.json # Per-issue detail cache
issue-<N>-ai.json # AI summary cache
pulls-cache.json # Open PRs (schema-versioned, + poll probe)
pulls-closed-cache.json # Closed+merged PRs (capped at 100)
pull-<N>.json # Per-PR detail + timeline + checks cache
pull-<N>-ai.json # PR AI summary + the fingerprint it was built from
recommendations-cache.json # AI label taxonomy
tagging-cache.json # Per-issue label proposals for the untagged queue
investigation-<N>.json # Per-issue investigation record
watch-state.json # Watcher high-water mark
That tree is PUBLIC GITHUB's, and it keeps the original layout so an install that
has been triaging GitHub issues keeps every cache, setting and investigation note
exactly where it already is. Every other provider+host pair is rooted under a
reserved segment first — @providers/<provider>/<host>/repos/<owner>/<repo>/, via
store.provider_root passed as the root= argument the ~40 per-repo store
functions already accept, so none of their bodies change. The segment is reserved
rather than merely conventional: a GitHub owner is constrained to
[A-Za-z0-9._-]+, so no real owner can produce @providers and collide with the
subtree. The host is part of the path because group/project on gitlab.com is a
different project from the same path on a private instance; for Azure DevOps the
host is pinned and the legacy visualstudio.com form is canonicalized before it
gets here, which is what keeps one organization from occupying two of these trees.
<owner> is the whole namespace, so an Azure repo's data lands under
@providers/azure/dev.azure.com/repos/<organization>/<project>/<repo>/.
config.json RMW operations are serialized via a cross-process file lock
(platform_compat.file_lock on config.json.lock). tagging-cache.json holds
the same lock discipline on its own .lock sidecar: every mutation is a merge
(generate) or a prune (apply) over the whole document, so overlapping cycles
would otherwise lose an update. An analysed issue the model declined to label is
stored as an EMPTY list, not omitted — otherwise "the next un-analysed slice"
would return the same unlabelable issues forever.
Permissions
Write routes (/labels/apply, /labels/apply-bulk, /issue/state,
/labels/create, and every MUTATING /pull/* + /pulls/bulk action) are gated on
confirmed triage or push access (_repo_can_write returns True — unknown
permission is denied, not allowed). Read-only repos degrade to suggest-only. Every PR
mutation goes through one _pr_action_preamble helper for the
JSON/owner/connected/permission checks, so the gate is not re-implemented per handler.
GET /pull/runs is a READ and is gated on the connected-repo check only, like the
other reads — it returns run metadata the PR's own checks already imply.
Pull-Request Actions
The write half of the PR pane — approve / request changes, comment, close / reopen, merge or arm auto-merge, cancel or re-run CI — available per-PR from the detail header and, for the actions that are safe to repeat, in bulk from the list. Six rules, each a deliberate narrowing:
-
Merging is offered in two forms, and the app refuses an unsatisfied PR itself rather than relying on the provider to.
/pull/mergelands a PR that is ready now;/pull/auto-mergehands one that is not yet ready to the provider to land once its checks pass. An earlier revision shipped only the second, reasoning that a direct merge could land unreviewed code — which left a repository with no branch rule (where auto-merge is unavailable) with no merge path at all. Why the app has to do the checking. It is tempting to say "the provider adjudicates": branch protection is enforced on its merge endpoint, and an unsatisfied PR comes back 405. That is true for an ordinary user and false for the account that matters most — a repository admin holding bypass-branch-protection, for whom the provider honours the merge. Andmergeablealone does not mean "ready": it means only "no merge CONFLICTS", so a PR with unsatisfied required reviews ismergeable: truewithmergeable_state: "blocked". Gating on it therefore offered the most privileged account a one-click way to land a PR its own rules had rejected. So the route re-reads the PR and refuses anything outside_MERGE_ALLOWED_STATES(clean/has_hookson GitHub,mergeableon GitLab and on Azure DevOps) with a 409merge_not_ready, and the UI mirrors the same set so the button never appears where it would only be refused. Two exclusions are load-bearing:unstableis often described as "only non-required checks are failing", but the state does not actually distinguish a failing required check from an optional one, so it cannot be read as "protections satisfied".- GitLab's legacy
can_be_mergedis the subtler one._norm_pullfalls back to the oldmerge_statusfield whendetailed_merge_statusis absent (a pre-16.x server, or a payload that omits it), andmerge_statusreports only whether the branches conflict — it is GitLab's exact analogue of GitHub'smergeableand knows nothing about unmet approvals, unresolved blocking discussions or a red required pipeline. Admitting it reproduced the very hole this set exists to close, on the servers least likely to be watched. Its modern replacement (detailed_merge_status: "mergeable") does imply those rules are met, and is the one GitLab value in the set. Note the read side still reportscan_be_mergedasmergeable: true— "no conflicts" is a true, useful signal for the pane's warning; the merge gate keys off the raw status instead, which is whygitlab_client._MERGEABLE_STATUSESandroutes._MERGE_ALLOWED_STATESdeliberately differ.
A gate that cannot tell must refuse — and such a PR is still one click from
auto_merge, which lets the provider decide once the checks finish. A provider 405 is still mapped to a readable refusal, since Method Not Allowed on a merge button reads like an app bug. The merge is PINNED to the reviewed head commit.head_shais required by the route (400head_sha_required) and by both clients, and rides as the provider's ownshaprecondition — so a push landing between the read and the click answers 409 instead of merging. The route also refuses when the live head has moved since its own state read: that state describes the commit it was read for, not a newer one. The UI does not offer the button until it knows the sha. The merge METHOD is per-provider, and the tuples deliberately differ._pr_merge_method_fieldreadsPR_MERGE_METHODSoff the key's own client rather thangithub_client's copy — which an earlier revision did, and which worked only because the two happened to match. They no longer do: GitHub's/mergeacceptsMERGE/SQUASH/REBASE, but GitLab's has no rebase option at all — merge-commit vs. semi-linear vs. fast-forward is the project'smerge_methodsetting, and the only per-request lever issquash. AcceptingREBASEthere translated it tosquash: false, so GitLab produced a merge commit: the caller named one history shape and silently got another, on the one operation that cannot be undone.REBASEis therefore absent fromgitlab_client.PR_MERGE_METHODSand a request for it is a 400invalid_merge_method— the same refuse-rather-than- approximate rule the client follows for "request changes" and a full CI re-run. (GitLab's separate/rebaseendpoint does not merge, so it is not a substitute.) Azure DevOps sits with GitHub rather than GitLab here: all three shapes are a per-requestcompletionOptions.mergeStrategy, soazure_client.PR_MERGE_METHODSis the full tuple and nothing has to be refused. There is deliberately no "override and merge": an override is a governance decision recorded ON the provider (this repo does it with a reviewed/ai-review overridecomment), and shedding a required check is the one thing no automatic gate should do quietly.test_pr_actions.py::TestMergeBoundariesandTestMergePrimitivepin all of it. -
A REVIEW is pinned to a commit too, for the same reason a merge is. Approving is a verdict on a revision, not on a pull request. Left unpinned, the review attaches to whatever the head is when the request lands — so a force-push between the render and the click records an approval of code the reviewer never saw, and on GitHub that approval can then satisfy a required-review rule. So
head_shais required by/pull/review(400head_sha_required, via the same_pr_head_sha_fieldthe merge route uses) and by both clients, and rides to the provider as GitHub'scommit_idonPOST .../reviewsand GitLab'sshaon/approve. The provider parameters are not equivalent, and only one of them refuses. GitLab'sshais a real precondition. GitHub'scommit_idis only attribution: GitHub accepts a review naming a commit that is no longer the head, records it against that commit, and whether the resulting stale approval still counts toward branch protection depends on the repository's "dismiss stale pull request approvals" setting — so wherever that is off, an unchecked approval satisfies protection on code nobody read. The pin therefore makes the verdict honest but cannot by itself make a stale one fail. The refusal is the ROUTE's job, and it is the same shape as the merge gate:_refuse_if_head_movedre-reads the PR's live head and answers 409review_conflictbefore the provider call, for both verdict verbs and for every pinned row of/pulls/bulk(there, as that row'sfailedentry, so the batch still applies and the row stays ticked for a retry). That re-read passesresolve_mergeable=False: it needs onlyhead_sha(returned eagerly), so it must NOT pay GitHub's lazy-mergeability retry (a 1.5s sleep + a second call, which fires on the common cold-unknownread) — that runs per row of a bulk approve, so on a 50-PR approve the default path was ~75s of serialized sleep. Skipping it cannot weaken the pin: the read is still a live read of the current head. A plaincommentreview skips the check — it records no verdict, so it stays valid prose whatever the head does. An unknown live head is deliberately not a refusal: fail-closed on a read gap would cost the feature on a provider that reports no head without buying any safety, since the sha still rides to the provider. The UI does not offer the two verdict buttons until the detail read has told it the head commit; commenting is not a verdict and needs no pin. In bulk this is per PR. A bulk approve is N verdicts, so/pulls/bulktakes ahead_shasmap keyed by number (not a parallel array — a client that reorders or filters its selection would otherwise pair a sha with the wrong PR) and requires an entry for every number in the request. A partial map is a 400 rather than being honoured for the subset that has one: approving fewer PRs than the button's own count claims is its own defect._PINNED_BULK_PR_ACTIONSnames the verbs this applies to — close, comment and the auto-merge pair act on the pull request itself and mean the same thing after a push, so they take no sha. To make this possible without an extra round trip per row, the list payload carrieshead_shaon both providers (github_client._PR_JQ,gitlab_client._norm_pull), and the client builds the map from the rendered rows — the sha the user saw is the sha the approval applies to. The client must snapshot that sha, not re-read it at submit time. Both the detail and the pulls queries POLL, so reading the live value when the button is pressed let a force-push landing in the window re-point the verdict at the new head — and the server-side pin cannot catch that, because the request would carry the new sha and there would be nothing to refuse. SoPrActionsBarfreezes the sha when the composer OPENS (oneopenComposerhelper, so the snapshot cannot be forgotten at one of three call sites) andPrBulkBarrecords each row's sha when it is TICKED (first observation wins; a row leaving the selection forgets it, so a re-tick picks up what is showing then). The snapshot is seeded during render rather than in an effect — a bar mounting with rows already ticked would otherwise have an empty map on its first pass and offer no approve at all. The freeze is per-composer/per-tick, not permanent: reopening after a real refresh names the new head. Three frontend tests pin the retarget cases. The person-filtered view needed a second source. That list is served by/pulls/search, and GitHub's search API does not expose the head commit — so the "assigned to me" view could not be bulk-approved even though the plain list could. Rather than a call per row, the sha rides on the by-number card enrichment (_PR_SUMMARY_SELECTIONgainedcommit{oid}), which already walks the head commit for its check rollup;_apply_summariesfills the field only when the row does not already have one, so the list row's own sha — the one the user saw — is never replaced by a newer one the enrichment happened to read, and a failed enrichment leaves it alone rather than blanking it._PR_SEARCH_JQcarries the key asnullfor row-shape parity. GitLab needs none of this: its search rows go through_norm_pulllike every other row.test_pr_actions.py::TestReviewIsPinnedToACommitandTestReviewRoutePinningpin it. -
Bulk is a fixed allowlist, not a generic fan-out.
_BULK_PR_ACTIONSnames the six verbs the bulk endpoint accepts.request_changesis per-PR only (a mass change-request carries no per-PR reasoning) and so ismerge— irreversible, and 50 from one click is a blast radius no confirmation makes reasonable; arming auto-merge is the bulk-safe equivalent. The batch runs SEQUENTIALLY — the PRs share one provider rate limit, and a 50-wide parallel fan-out is how a bulk click becomes a secondary-rate-limit block that fails rows for no reason of their own. The cap (_BULK_PR_MAX= 50) is published on every/pullsand/pulls/searchresponse asbulk_max, and the client CHUNKS on it. Neither is optional: the server rejects an over-cap batch outright, so an unchunked "select all" on a repo with more open PRs than the cap was a flat 400 with nothing applied — and a hardcoded client copy of the number breaks silently the day the cap moves (the same reasoning/tagging'sbulk_maxalready documents). -
Partial failure is reported, never swallowed — the same contract as
/labels/apply-bulk: per-PRapplied/failedlists, so one locked or already-merged PR does not discard the rows that succeeded, and the caller is never told about a write that did not happen. In the UI the SUCCEEDED rows are unticked and the failures stay selected, so a retry hits exactly the rows that still need it — keeping the whole selection would re-apply to the ones that already worked, which forcommentposts a visible second copy. Relatedly, a refusal is not an exception on every provider: GitLab answers 200 with a non-merged state and amerge_errorwhen its approval rules say no, so the merge path checksmergedbefore touching any cache. Trusting the return value would evict a still-open PR from the open list and report success. -
Every action is permission-gated and SEL-audited, and a per-PR authorization refusal inside a bulk run is audited as
denied, notfailure— collapsing the two (they share an exception base) would make a refused mutation indistinguishable from a network timeout, so a query foroutcome=deniedreturned nothing for the whole bulk surface. -
Every action drops the caches it invalidated. A close/reopen — and a merge, which also closes the PR — removes the row from the list it left (
apply_pr_state_change_to_caches) and drops the PR's detail entry; everything else drops just the detail (drop_pr_detail_cache). The merge path applies that change only after confirming the provider actually merged (see rule 4). Without this,PR_DETAIL_CACHE_TTL_SECis long enough for a user to click a button and watch nothing happen.
Provider divergence is refused, not approximated. GitLab has no "request changes"
verb (the closest thing, unapproving, is not a verdict on a revision) and its
/retry only retries failed and canceled jobs — so submit_pr_review raises for
REQUEST_CHANGES and rerun_workflow_run reports failed_only: true regardless of
what was asked. Azure DevOps refuses BOTH verdicts, for the ordering reason under
Providers: its vote cannot be bound to the revision it was formed on.
Reporting a verdict the platform never recorded, or a full re-run that did not
happen, would be worse than the error. GitHub is the only provider that expresses
this whole surface natively.
The sharpest instance, because it is a security property rather than a cosmetic
one: GitLab auto-merge is REFUSED outright. GitLab has no independent "arm" verb —
merge_when_pipeline_succeeds is a modifier on the merge endpoint, and with no
pipeline in flight GitLab merges the MR immediately. A revision of this change tried
to contain that by reading the head pipeline first and arming only when a run was
live, but that check is not atomic: a pipeline finishing between the read and the
call turns the same request into an immediate merge. Since arming is offered as a BULK
action with no typed confirmation (it is advertised as reversible), losing that race
would merge a whole selection irreversibly — so enable_auto_merge /
disable_auto_merge raise on GitLab and the UI hides both controls there rather than
narrowing the window and hoping. The capability is relocated, not lost:
merge_pull_request covers "merge now", and GitLab's own web UI owns the deferred
case. An MR armed on GitLab still displays as armed, since the read-side
auto_merge detail field is unaffected.
On GitHub, where enablePullRequestAutoMerge is a real, separate mutation, arming is
offered normally — and auto_merge is derived from the returned autoMergeRequest
rather than asserted, because a hardcoded True is a claim rather than an observation.
Azure DevOps is in the same position for the same reason: autoCompleteSetBy is an
armed STATE on the pull request, set to an identity to arm and to the empty GUID to
disarm, so arming there is genuinely reversible and is read back off the returned
field rather than assumed.
Merge readiness is on the LIST row, because a BULK action is what needs it.
enablePullRequestAutoMerge is only valid for a PR that is not landable yet: GitHub
refuses one that is already mergeable (Pull request is in clean status) and one that
has already merged (Pull request is already merged). The bulk bar had no way to know
either — _PR_JQ carries no mergeability at all, only _PR_DETAIL_JQ did — so it
offered "arm auto-merge" for every ticked row and collected one provider refusal per
row from a single click. Six of seven failures in the observed case were PRs that were
simply READY, i.e. the operator's actual intent was to merge them.
So _PR_SUMMARY_SELECTION also requests mergeable, state and mergedAt — free,
because that selection already walks the head commit for its check rollup, the same
trick that gave SEARCH rows a head_sha.
mergeStateStatus is NOT free, and travels in its own query. It is the one field
here GitHub has to COMPUTE (a merge commit per PR) rather than read. Folded into the card
selection — which already walks each head commit and paginates the whole check rollup —
the combined query reliably 502s at first:100. Measured, same page size, same repo:
the selection without it succeeds; with mergeable/state/mergedAt added it succeeds;
with mergeStateStatus added it fails every time. Alone, with no rollup and no commit
walk, the same field is comfortable at first:100.
The failure would not have been graceful. Both enrichment paths carry that selection, so
a 502 leaves every row with a null diff size and null check tally,
enrichment_complete returns False so the route declines to cache, the list refetches
on every load — and mergeable_state ends up None for all of them, leaving the bulk
bar exactly as blind as before. That trades a seven-refusal annoyance for a total
enrichment outage on the large repos most likely to use bulk actions. So readiness is a
second, LEAN call (fetch_pr_readiness / fetch_pr_readiness_by_number,
_PR_READINESS_SELECTION): one extra request per list fetch, independently failable, and
a failure costs only the readiness field. Its by-number batch is 50, not the
summaries' 100, because the by-number form asks for N computed fields in one query.
test_readiness_is_a_SEPARATE_query_from_the_card_enrichment pins the field out of the
card selection, and test_readiness_survives_a_FAILED_card_enrichment_and_vice_versa
pins the independence.
Both calls are topped up by number, and the top-up tests MEMBERSHIP. The
state-scoped readiness query is capped at first:100 like the summaries', while the REST
list paginates every open PR, so on a repo with more than 100 the tail carried no
readiness at all, and since unknown readiness is offered NEITHER verb those rows were
silently unactionable in the bulk bar, on exactly the large repos bulk actions exist for.
The top-up asks for the numbers whose key is ABSENT from the result, never the ones whose
value is falsy. UNKNOWN is a real answer rather than a missing one, recorded as the
string 'unknown' (the None mapping belongs to the separate mergeable field), and it
is roughly half a cold page, so a truthiness test would re-request every such row on every
list fetch and get UNKNOWN back. test_readiness_is_topped_up_past_the_hundred_row_window and
test_the_top_up_tests_MEMBERSHIP_not_truthiness pin the two halves.
Three further properties are load-bearing:
- Normalized into REST's vocabulary at the parse boundary. GraphQL SHOUTS its enums
(
CLEAN,MERGEABLE,OPEN) where REST is lowercase, so_parse_summary_rowslowers them;routes._MERGE_ALLOWED_STATESand the frontend'sMERGE_READY_STATESboth compare lowercase, and an un-loweredCLEANwould match neither and read as "not ready" — silently keeping the broken arm on offer. UNKNOWNstays unknown, and it is the COMMON case. GitHub computes mergeability asynchronously, and on a cold read roughly half a page can come backUNKNOWN(measured)._graphql_mergeablemaps it toNone, neverFalse, and a failed enrichment writesmergeable_state: Nonerather than omitting the key — an absent value is falsy, so it would read as "not ready" and put the row straight back into the batch the provider refuses. A row of unknown readiness is offered NEITHER verb: a gate that cannot tell must refuse. (The detail path retries after 1.5s for exactly this reason; the list path does not, so a first visit legitimately shows fewer actionable rows than a second.)- Four distinct refusals, not one. The provider declines to arm a PR that is already
mergeable, already merged, a draft, or
dirty(a conflict — no check resolves it, so there is no "once checks pass" to wait for).canArmAutoMergeexcludes all four;canMergeNowshares the same lifecycle gate via oneisOpenCandidatehelper rather than open-coding it, because two copies of a security-relevant predicate diverge — the first review of this change caught exactly that, one copy checkingdraftand the other not. MERGEDcollapses toclosed+ a timestamp. GraphQL has a third lifecycle state; REST models the same fact asstate: "closed"withmerged_atset, and the row shape is REST's._rest_pr_statetherefore returnsclosed, and_apply_summarieswritesmerged_atin the SAME step —PrList.prStateVisualchecksmerged_atfirst, so aclosedwith no timestamp renders as the red closed-unmerged icon, and a literal"merged"would match no branch at all and paint a merged PR as open.merged_atis only ever gap-filled, never overwritten.
The ready rows get a merge path, and it is NOT the bulk endpoint. Rule 3 keeps
merge out of _BULK_PR_ACTIONS — irreversible, and 50 from one click is a blast
radius no confirmation makes reasonable. But refusing to arm a ready PR while offering
it no way to land was the gap that made the original click fail, so the bulk bar offers
a sequential merge (useSequentialMerge) over exactly the rows the provider already
reports mergeable. It drives the ordinary per-PR /pull/merge route once per row, so
every property of that route still holds per row: the head_sha pin (snapshotted at
TICK time, never re-read at submit — the list polls), the server-side
_MERGE_ALLOWED_STATES re-read, the permission gate and the SEL audit. It is gated on
its own typed token, distinct from the bulk-close token so typing one cannot arm the
other, and it is capped at the server's published bulk_max — the loop does not use
the bulk endpoint, so nothing else would bound it, and rule 3's "50 from one click"
reasoning applies to a loop exactly as much as to a batch.
Four properties make it safe to offer, each one a defect a review found and closed:
- The target set is FROZEN when the confirmation opens. The list polls, so a
readiness change landing between "type the token" and "press Apply" would otherwise
change what gets merged — and since a cold read reports
unknown(neither bucket) and resolves toclean(ready) moments later, a warning that said "1 pull request" could execute six.armedMergeholds the frozen set; the warning text and the Apply count both read it, so the number shown is the number that runs. - It stops at the first refusal. Each merge changes the base branch, so a later PR's mergeability is a function of the earlier one having landed; continuing past a failure would merge onto a base that no longer matches what was reviewed.
- Cancel ABORTS a run in progress, not just the composer. The in-flight merge cannot
be recalled — that request is with the provider — but every row after it is spared,
which on an irreversible mass action is the difference between Cancel meaning something
and meaning nothing. A second entry point (the confirm input's Enter) is guarded on
busytoo, or two loops advance independently and defeat stop-on-first-failure. - The per-row report is
aria-liveand names the row in flight, because the rows stream in one at a time and "which PR did it stop on" is the only question that matters afterwards. The "N not attempted" line counts against the size the run STARTED with, since merged rows untick themselves and so leave the live target list. - The run's OWN selection churn is exempt from the selection-reset effect. A
successful merge changes the selection twice. The per-row invalidation refetches the
list, so the merged PR leaves it (and
numbersis intersected with what is RENDERED), and the run unticks it afterwards. That effect could not tell either from a user click, so it fired mid-run: it wiped the per-row report at the moment it completed, and itsreset()setaborted, silently skipping every remaining row of a 3+ row confirmation with no refusal to explain it. Two changes close it, each independently load-bearing (each has its own failing test under mutation):mergedByRunrecords what the run merged, and the REPORT key adds those numbers back rather than filtering them out, because a merged row was in the key before it merged, so removing it changes the key just as much as its disappearance did (7,8->8); re-adding reproduces the pre-run key exactly. A genuine reselection still resets, because it moves a number the run never merged.- TWO keys, and the exemption belongs to only one of them.
selectionKeyis the LITERAL ticked set and disarms an in-progress action;reportKeycarries the exemption and decides only whether the run report is stale. Folding them into one key let the exemption mask an ADDITION as well as the run's own disappearance: withmergedByRun = {7}, ticking a live #7 left the key unchanged, so a typed CLOSE confirmation stayed armed and Apply closed a PR whose count the warning never included.mergedByRunis also reset whenscopeKeychanges, since PR numbers are per-repo and this component is not remounted by a repo switch. - The disarm stands down while a merge run is BUSY. Cancel lives in the confirmation
composer, which renders only while
pendingis set, and the first merged row changes the ticked set, so disarming on it took the only control that stops the remaining rows off screen mid-run. An irreversible loop has to stay interruptible for as long as it is running; the run clears the composer itself when it ends. This is independent of the per-row veto above, which reads the live selection rather than the composer. - the effect calls
clearReport, which drops the stale report WITHOUT aborting;reset(which aborts) stays for Cancel and the explicit clear. A caller that only wants to tidy the UI must not be able to stop a merge in flight. The test harness feeds both feedbacks back (renderWithLiveSelection); the inertvi.fn()every other case passes is why this went unseen, and the mid-run drop is the half that reproduces the abort.
- Unticking a QUEUED row still stops that row, as a per-row veto. Exempting the run's
own churn removed the one thing the old unconditional reset got right: an operator
deselecting a PR the run has not reached yet is withdrawing consent for that PR, and the
loop reads the frozen
armedMergeprecisely so a poll cannot change what executes, so nothing else would notice.mergeAlltherefore asksstillWanted(number)against the LIVE ticked set as it reaches each row and skips a deselected one. It is a per-row veto, NOTabort: the rows after it were not withdrawn, andabortis what Cancel means. The predicate is ref-held so it never enters the selection effect's dependencies, which must stay keyed on the selection alone or an unrelated render wipes a confirmation mid-entry. - The confirmation names the frozen SET and the merge METHOD, not just a count. A
count cannot be checked against what the user believes they ticked, and the method is
hardcoded, so a merge-commit repo would otherwise discover the squash only afterwards.
The list is bounded by
bulk_maxso it is always short, goes throughfmtListfor the locale's own enumeration, and renders each number as raw digits behind the PROVIDER's own sigil (terms.sigil, so GitLab reads!7, matching the run report directly below it and the operator's own tab; merge-now is reachable on GitLab, only the two AUTO-merge buttons are gated there). Raw digits because a PR number is an IDENTIFIER: a grouping separator would both misrender it (#1,291) and break copying it.SEQUENTIAL_MERGE_METHODis the single symbol behind both the copy and the request, so the two cannot drift. The line is NOT muted, being the only one that names WHICH pull requests are about to merge, and it wraps rather than truncating: clipping the identifiers would defeat the inspection it exists for. The confirmation input points at both lines witharia-describedbyand takes focus when it arms.
This deliberately accepts bulk-merge's blast radius; the mitigations are the readiness precondition, the cap, the frozen typed confirmation, and stop-on-first-failure.
Refresh preferences
How fresh the app feels and how much of the provider's hourly request budget it spends
are the same dial, so the intervals are user-settable (Settings → General → Refresh)
rather than hardcoded. They persist in the app's localStorage UI state alongside the
filters — they are per-browser view preferences, not repo configuration.
| Setting | Default | Effect |
|---|---|---|
| List refresh | 60s | refetchInterval on the issue + PR lists |
| Detail refresh | 30s | refetchInterval on an OPEN item; a closed one keeps CLOSED_DETAIL_POLL_MS regardless |
| Use cached data for | 30s | react-query staleTime — how long a fetch counts as current on mount/focus |
| Keep refreshing in background tabs | off | refetchIntervalInBackground; off means a hidden tab stops and is stale on return |
| Load pull requests on open | off | lifts the prSurfaceActive gate so the first visit to the PR pane is instant |
Two safeguards, both deliberate:
- The values are an allowlist, not a range.
coerceIntervalaccepts only an offered choice and falls back to the default otherwise, on WRITE as well as on read, so a hand-editedlocalStoragevalue cannot install one.Infinitymatters specifically: react-query reads it as "no interval", so it would silently DISABLE polling rather than speed it up.0is a real choice for the cache lifetime (it means "always refetch"), which is why the check isincludes, not truthiness. - The 30s list floor is twice the backend's probe-coalescing window, and that is the
whole reason it is 30s. The binding constraint is NOT the 5,000/hr core budget — a
poll is probe-gated, so its steady-state cost is one search call, not a paginated
refetch — it is GitHub's 30/min search quota, which the probe spends and the user's
own
gh searchshares._PROBE_COALESCE_SEC(15s) shares one reading per (repo, kind) across every open tab, so a 30s interval costs at most 2 probes/min/kind however many tabs are open. Halve the floor and that stops holding. Nothing enforces the relationship across the language boundary, soissueRadarPolling.test.tsxassertsmin(choices) == 2 × 15s: if the backend constant moves, that test is what says this floor must move with it. /pulls/searchopts out of BOTH new knobs. Prefetch does not reach it, andrefetchIntervalInBackgroundis deliberately not applied to it either — it is the one query in the app that ignores that setting. Every other poll is probe-gated, so background polling costs one shared probe; this route has no probe path at all, so each poll is a real provider search (up to 3 pages) against the same quota with nothing to absorb it. Honouring the toggle would let a person filter someone left on months ago spend that quota indefinitely — exactly what its surface gate exists to prevent.
Surfaces stay CACHED across a tab switch
Every dashboard mounts its own queries and unmounts them on the way out: the views are
SWAPPED, not hidden (views/registry.tsx maps a tab to one component). Data for an
unmounted query survives only gcTime past that unmount, and the dashboard-wide default is
react-query's 5 minutes, which is shorter than an ordinary triage session. Leave the
Tagging dashboard for six minutes, come back, and its queue has been evicted: a loading
line, then a full refetch. Once per tab click.
IssueRadarPage therefore sets ONE query default for the whole ['issue-radar', ...] key
space: gcTime = CACHE_RETENTION_MS (30 min). Four properties:
- Set once, for the key space rather than repeated across the ~30 query sites, because
a per-site option is one a newly added query silently forgets. Every key in the app
already starts with the
issue-radarsegment, which is what makes one default reach all of them. - Scoped to this app. Raising the global default would retain every other page's queries too, which is memory spent on data nothing asked to keep.
- Retention is not freshness.
staleTimeand the poll intervals still decide when a refetch happens, so a longergcTimeonly changes whether there is something to paint WHILE that refetch runs. It can never serve something stale instead of fetching. - Bounded, not
Infinity, so a long-lived tab that has visited many repos does not retain every one of their lists for the life of the session.
That is sufficient on its own because the surfaces gate their loading copy on isLoading,
which is false whenever data is present: a remount inside the retention window paints the
retained rows immediately and any refetch runs behind them. issueRadarPolling.test.tsx
pins the retention, its scoping, and the not-pending property.
The lists additionally keep their previous rows on screen while a new key loads, so changing a filter repaints instantly instead of blanking to a spinner. That costs no extra requests — it only changes what is shown during a fetch that was already happening.
Scoped to the repo, not plain keepPreviousData. That helper retains the previous
query's rows for ANY key change, which conflates two different transitions: a filter
change is a different view of the SAME repo (retain — that is the point), but a REPO
SWITCH is not. A PR number means something different in each repo, so repo A's rows
painted under repo B's identity make a row actionable against B's PR of the same number.
keepWithinRepo compares the previous query key's scopeKey (provider + host + slug, so
a same-slug repo on another host is correctly a different repo) and returns undefined
across a switch, which renders the honest loading state. The ticked selection is already
cleared on scopeKey, but that effect runs after the paint — it closes the window one
render late rather than never opening it, which is why the placeholder itself is scoped.
issueRadarPolling.test.tsx pins both halves.
add_pr_comment is a separate function from add_issue_comment even though the two
coincide on GitHub (one number sequence per repo): GitLab numbers issues and merge
requests INDEPENDENTLY, and on Azure DevOps a work item and a pull request are
allocated by different services entirely, so a single shared entry point would be a
silent way to comment on an unrelated item. The ProviderClient protocol and the
TestClientParity surface list both.
The UI reads the PR detail's auto_merge field to decide whether it offers "enable"
or "cancel", which is why PR_DETAIL_CACHE_SCHEMA is at v5 — a v4 entry has no
such key, and defaulting it to absent would show "enable" on an already-armed PR.
PULLS_CACHE_SCHEMA moved to v6 for the same reason: the list row now carries
head_sha, and a v5 row served as-is would silently disable bulk approve for every
already-cached repo until its TTL expired — a broken-looking button rather than a
visibly stale list. It moved again to v7 when the row gained mergeable_state /
mergeable (see "Merge readiness is on the LIST row" below): a v6 row has neither
field, and an absent value is indistinguishable from "not ready", so serving one would
keep offering exactly the arm that fails.
CI runs are fetched separately from /pull's checks, because a check is a per-job
RESULT (and may come from a service with no runs at all) while cancel/re-run acts on
the parent RUN and needs its id.
Security Controls
- Spawn hardening: All
ghcalls funnel through_gh_run, a thin wrapper over the shared hardened runner (kiro_crew.github_runner.run_gh), which resolves a canonicalghviagithub_runner.resolve_gh(KIROCREW_ISSUE_RADAR_GHoverride, thenKIROCREW_GH_BIN, then the well-known install dirs, then the ambientPATH) and validates it (and every parent) withgithub_runner.validate_provider_executable. The default policy accepts the user's OWN install (Homebrew/asdf/~/.local/bin) and refuses only provenance the user did not choose: a binary owned by another unprivileged account, a world-writable one (a world-writable directory is tolerated only when sticky, where the owner check still decides), or one inside the agent-writable project/workspace tree. A gateway running as root is refused outright in both modes.KIROCREW_PROVIDER_BIN_STRICT=1restores the historical root-owned, symlink-free requirement. The runner passes a minimal env (github_runner.gh_env— the safe-key base plus gh-scoped auth/network/TLS vars, with ambient ssh-agent/git-ssh identity stripped; no unrelated gateway secrets) and pinsGH_HOST=github.com, because the GitHub client is github.com-only by design (GitHub Enterprise is unsupported for the same reason on-premises Azure DevOps Server is — see Providers) and its bare API paths never pass--hostname, so an ambient enterpriseGH_HOSTmust not steer them. The runner is benign-allowlisted in the spawn audit once (github_runner.py::run_gh), covering every caller. - Every provider has the same chokepoint, one per CLI.
gitlab_client._glab_runandazure_client._az_runmirror_gh_run: one function every spawn passes through, argv[0] replaced with the validated canonical binary (resolved through the sharedsource_providers.provider_executable_candidates+_validate_provider_executablepolicy, withKIROCREW_ISSUE_RADAR_GLAB/KIROCREW_ISSUE_RADAR_AZas the override), a list argv rather thanshell=True, and a minimal environment instead of the gateway's._az_envpasses only az's own auth roots (AZURE_CONFIG_DIR,AZURE_EXTENSION_DIR) plus proxy/TLS vars, and forwardsAZURE_DEVOPS_EXT_PATonly for the pinned cloud host — it is a single ambient credential with no host binding, so sending it anywhere else would hand a dev.azure.com token to that server. It also setsAZURE_EXTENSION_USE_DYNAMIC_INSTALL=no, which is a security setting rather than a convenience one: with dynamic install on, a call naming an unknown command group makesazdownload and execute an extension wheel, and an automated spawn must never install code — a missingazure-devopsextension has to surface as an error the user resolves themselves. - The host is re-checked at the spawn boundary, not only at the edge.
gitlab_client._resolve_hostandazure_client._resolve_hosteach re-validate the target before the process starts (Azure:dev.azure.comand nothing else, an empty host refused rather than defaulted), so a corruptedconfig.jsonentry or a future code path that skippedprovider.normalize_hostfails closed instead of pointing a credential-bearing CLI at a server the caller never named. - SEL audit: Every
_gh_runinvocation is audit-or-deny: the shared runner writes a fail-closedinvokedevent before the spawn (SEL storage unusable ⇒ the gh call is refused, surfaced as a retryableGhCliError), then a best-effort outcome event on success/failure/timeout — emitted inside the shared runner so no gh caller can drift out of the audit. The GitLab and Azure runners audit their own spawns the same way (azure_client._audit, oneissue_radar.*access event perazcall, reads included). Write handlers additionally emit denied/ok/failure events around the permission check and mutation. - Input validation: Owner/repo are charset-restricted and the host is checked
against the provider's own rule — allowlisted exactly for GitHub and Azure
(
_PINNED_HOSTS), and against the operator'sdashboard.gitlab_hostsallowlist for a self-managed GitLab (SSRF guard). A pasted URL is dispatched on its PARSED hostname, never on a substring of the URL, so text appearing in a path or query cannot route it to another provider's parser. Numbers areint()-coerced. Write bodies go via JSON stdin, never argv. Request bodies validated asdictbefore.get(). - Enabled-state guard: All handlers wrapped in
_require_enabled; returns 403 when the app is disabled. - Prompt-injection containment: The AI routes feed UNTRUSTED repo text to the
model — an issue body, and for
/pull-aithe PR description plus every comment and review. That payload is fenced in explicit markers and declared as data, the call runs in a tool-less ephemeral session (REJECT_ALLapprovals), and the output is redacted. Issue label suggestions are additionally intersected with the repo's real label set, so injected text cannot invent a label; a PR summary is prose that nothing downstream acts on. - Output language: when
dashboard.languagenames a shipped catalog, each AI prompt appends a directive (outside the untrusted fence) to write its PROSE — the summaries, per-labelreason, and recommendationrationale— in that language. Label names stay verbatim (the intersection above still matches), and a recommendation'sname/descriptionstay in the repo's own language because/labels/createwrites them onto GitHub. With no configured language the prompts are byte-identical to before and the model defaults to English. Caches follow suit: the PR-summary fingerprint folds the tag in, and the issue-AI cache stores the tag beside the payload and treats a mismatch as a miss, so a language switch regenerates both on next open. The recommendations cache is regenerate-only by design — it renders the last explicitly generated proposal until the user presses "Recommend labels" again.
Background Watcher
An in-process asyncio loop (watch.py) polls opted-in repos every 60s for new
issues (high-water mark in watch-state.json). Sends dashboard bell
notifications via state.notify. Zero-LLM. Guarded by is_app_enabled — silent
when disabled. Lifecycle hooks registered via app.on_startup/on_cleanup.
First Paint (progressive open-list load)
The open-issue list is fully paginated: list_open_issues follows every Link
page in one gh --paginate process (a ~2.6k-issue repo is ~26 sequential requests
under GH_PAGINATE_TIMEOUT_SEC), then writes a multi-MB cache. That cost is paid
in-band before the list can render, so a COLD open (first-ever open of a repo, a
freshly connected one, or after an ISSUES_CACHE_SCHEMA bump invalidates the cache)
blocked on a skeleton for seconds. Every WARM re-open is already instant — the list
cache has no TTL and is served immediately — so this is a cold-cache-only problem.
The open-PR list is the same shape and worse: list_open_pulls paginates every
page AND the route then runs enrich_pulls (the GraphQL summaries + readiness
families) before a byte can render, so a cold PR pane is the app's slowest open. Both
lists therefore carry a first_page=1 fast path.
GET /issues?first_page=1 (open state only) is the fast path that ends the blank
wait, handled by _handle_issues_first_page:
- Warm cache → served whole,
partial: false, no fetch. When the full snapshot exists there is nothing to gain from a partial, and the fast path must not add aghcall the warm path does not pay. - Cold cache → the newest single page in ONE request,
partial: true.list_open_issues_first_pageis_list_issues(..., paginate=False): the SAME issue shape andsort=updatedorder as the full fetch, capped at oneper_page=100page, on the ordinaryGH_TIMEOUT_SECrather than the paginate budget. Because it is the same first page the full fetch returns, the complete set appends BEHIND it with no reordering when it lands. - It never WRITES the cache. The durable cache is owned by the full fetch, which
stores the complete rows plus the poll
probeunder one lock. Persisting a partial here would let a laterpoll=1serve an INCOMPLETE list as verified-fresh (and with no probe), so this path is strictly read-only — its result lives only in the client's transient first-paint query.
The client (context.tsx) runs firstPageQuery only in the exact cold window —
open state, and the authoritative issuesQuery has produced nothing for the key yet
(data === undefined, which also covers a cross-repo switch where keepWithinRepo
yields undefined). Its rows feed issues (and clear the skeleton) ONLY until the full
list resolves, after which it is disabled and its rows are ignored. It deliberately
does not feed issuesQuery.isSuccess: the one-shot auto-select and the members
gate both key off that, and a partial page must not satisfy "the repo's issues are
loaded". The list footer shows an issuesPartial "loading the rest" hint so the count
does not read as the whole repo. Net cost: exactly one extra single-page request per
cold repo-open. list_open_issues_first_page is on the ProviderClient protocol, so
GitLab and Azure DevOps each implement the symmetric single-page variant (Azure's is
one WIQL+hydrate pair capped at _PAGE_SIZE, in the same changed-date order as the
full list) and apps/builtins/issue_radar/tests/test_gitlab.py::TestClientParity::test_every_module_implements_the_whole_surface holds.
GET /pulls?first_page=1 (open state only) is the PR twin, handled by
_handle_pulls_first_page with one added rule: the first page is returned
UN-ENRICHED. Enrichment is the other slow leg the fast path exists to skip, so it
is deliberately not paid here; a row's missing diff/check data renders as absent (the
card's bottom row is omitted), never as a wrong "no diff, no checks", and the
authoritative fetch that runs next enriches and caches. Warm cache → whole,
partial: false, no fetch; cold cache → the newest single page
(list_open_pulls_first_page, _list_pulls(..., paginate=False)), partial: true, no
cache write (the full fetch owns the durable cache and refuses to persist incomplete,
un-enriched rows). The client runs pullsFirstPageQuery only in the exact cold window —
open state, no person filter (search owns that path and is already whole-repo), the
PR surface actually in use (same gate as pullsQuery, so no request is spent on an
unopened pane), and pullsQuery.data === undefined. Its rows feed pulls and clear the
skeleton until the full list lands; the footer shows a pullsPartial "loading the rest"
hint. list_open_pulls_first_page is on the ProviderClient protocol (GitLab's variant
is card-complete already, since it inlines head_pipeline; Azure's is un-enriched like
GitHub's, because its check state is a per-PR policy-evaluation call), and
apps/builtins/issue_radar/tests/test_gitlab.py::TestClientParity::test_every_module_implements_the_whole_surface holds.
enrich_pulls runs its two INDEPENDENT GraphQL families — card summaries and merge
readiness — concurrently on a two-worker ThreadPoolExecutor rather than
back-to-back. They must stay two calls (readiness cannot ride on the card selection
without 502ing it) and neither derives from the other, so overlapping their blocking
gh round trips makes the enrichment leg cost the slower family instead of their sum.
Each family (_enrich_summaries / _enrich_readiness) swallows its own GhCliError
internally, preserving the best-effort contract: one failing does not sink the other.
Client-Side List Polling
The issue and PR lists poll every 60s (LIST_POLL_MS, matching the watcher's
cadence so a bell notification and the row it refers to land in the same
window). Deliberately 6x the per-item detail interval (DETAIL_POLL_MS, 30s):
the open lists are FULLY paginated, so a whole-repo refetch is tens of REST
requests plus a multi-MB cache rewrite on a large repo, not one item's worth of
work.
A poll sends poll=1, NOT refresh=1. The client only declares intent ("I want
current data"); the cost policy lives server-side so it cannot be multiplied
by open tabs:
poll=1— probe-gated._poll_can_serve_cacheruns ONEgithub_client.probe_open_listsearch call ({total_count, top_updated_at}for the open set) and serves the cache untouched unless that reading differs from the one recorded when the rows were last fetched. Two fields because either alone has a blind spot:top_updated_atcatches a new/edited/commented item,total_countcatches a CLOSE (which leaves the open set without bumping any remaining timestamp).refresh=1— the unconditional cache-bust, used by the manual Refresh button. Unchanged semantics.- neither — cache-first at any age, so the app paints on open without waiting on
gh. This is what the FIRST fetch for a query key sends.
The probe reading is stored under a probe key inside the list cache file, and
is only ever compared probe against probe — never against the cached rows —
so a systematic difference between what search counts and what the REST list
returns cancels out instead of reporting "changed" on every poll. Rows and probe
are read in ONE read_*_snapshot call: reading them separately let a concurrent
refresh pair old rows with a new probe, which the poll would then serve as
verified. The reading recorded with a refetch is the one taken BEFORE the fetch,
so a change landing mid-fetch leaves the record behind reality and the next poll
refetches rather than hiding it. For issues the probe is handed to
store.refresh_issues_cache so it is persisted by the SAME locked write that
stores the rows — a second write after the refresh would reopen the window that
lock closes (a label applied in between would be overwritten). The label and
check write-through patches read-modify-write the whole payload, so they carry
probe and fetched_at through untouched.
LIST_POLL_MAX_STALENESS_SEC (10 min, every 10th poll) bypasses the probe and
refetches unconditionally. This is the backstop for a probe that is wrong
rather than unavailable — a consistently wrong reading matches its own prior
recording forever, which no error handling can catch. Two live cases: GitHub is
retiring PR results from search/issues (the advanced_search transition),
after which the is:pr probe degenerates to a stable {0, None} that compares
equal to itself; and a PR check run turning red changes neither updated_at nor
the open count, so no metadata probe can observe CI moving. (The PR you have
open stays current either way — its detail poll writes fresh check state back
into the list cache via apply_pr_checks_to_list_cache.) The ceiling bounds the
worst case to ~6 full fetches an hour, still an order of magnitude under the
unprobed cost.
The age the ceiling measures comes from a fetched_at stamp inside the cache
payload, not from the file's mtime. The write-through patches
(apply_pr_checks_to_list_cache, apply_label_change_to_caches) rewrite the file
without refetching anything, so with mtime the age reset every 30s for as long as
a PR pane was open — leaving the ceiling unreachable in exactly the
degenerate-probe case it exists to bound. A cache written before the field
existed falls back to mtime for one refresh cycle.
A probe error keeps serving the cache rather than refetching: a sustained probe outage (an exhausted search quota, say) would otherwise convert the poll into exactly the fetch-per-minute drain this path exists to avoid. Staleness is bounded by the ceiling above, which is the honest backstop.
_coalesced_probe shares one reading per (owner, repo, kind) for
_PROBE_COALESCE_SEC (15s), so the search quota (30/min, shared with the user's
own searches) does not scale with the number of open tabs. Concurrent polls for
the same key join one in-flight probe (a per-key future); the lock guards only
the memo/in-flight maps and is never held across the probe itself, so one repo's
gh timeout cannot stall another repo's or kind's poll. The reading is published
from the future's done-callback rather than by the awaiting request, so a client
that disconnects mid-probe still contributes the call it paid for.
Search is used rather than repos/.../issues because it reports total_count in
the same response and is:issue/is:pr keeps the two lists from triggering each
other.
Only the OPEN lists are probed; the closed lists are bounded to one
per_page=100 page, so refetching one is already a single request.
The PR poll is additionally gated on the PR surface being open (that fetch runs
the GraphQL enrichment), the base list and the person-filter search are mutually
exclusive so only the rendered source polls, and react-query pauses every poll
while the window is unfocused. Because those two sources are gated on different
flags — the base list stands down as soon as a person filter is requested, the
search query only starts once /me resolves — pullsLoading covers the gap
between them, or restoring a persisted person filter would render "no pull
requests" until the login lands.
The label cache expires, because an unbounded one reads as a TRUNCATED list
labels-cache.json had no expiry, and unlike the issue/PR lists nothing polls the
/labels query (it is a plain useQuery with no refetchInterval). So the first fetch
of a repo was served forever: a label created on GitHub afterwards, by a teammate or by
automation, was absent from the left-rail palette, the filter list and every picker until
the user happened to press Refresh. That presents as an incomplete label set rather
than a stale one, which is the reason it is worth a TTL: nothing on screen says the list is
partial, and the missing labels are silently unfilterable, so the user's conclusion is "the
app does not show all my labels".
LABELS_CACHE_TTL_SEC is 600s, and read_labels_cache treats an older file as a MISS
so the route refetches. Three properties, each with its own test:
- The TTL is the DEFAULT, not a per-caller argument. Freshness is a property of the
cache (the same rule
read_pr_detail_cachefollows), so a new route cannot forget it.max_age_sec=Noneis the explicit opt-out, andadd_label_to_cacheis the one caller that takes it, because it patches whatever is on disk, and reading an expired file as absent there would silently drop the append of a just-created label. - The age comes from a
fetched_atstamp INSIDE the payload, not from mtime, becauseadd_label_to_cacherewrites the file without refetching. This is the same trap_list_cache_age_secdocuments for the list caches, and it matters more here: the append carries the ORIGINAL stamp through, so creating labels in a repo cannot keep deferring the refetch that picks up everyone else's labels. - A pre-stamp cache falls back to mtime, on BOTH paths. Such a file was only ever written by a real fetch, so its mtime is its fetch time: it must age out rather than be treated as ageless on read, and the write-through append must carry that mtime over rather than stamping the current time. Stamping there reset the TTL clock, so a cache nine minutes into its ten-minute life got a fresh ten and one label creation per interval could defer the refetch indefinitely.
A plain TTL rather than the lists' probe-gated poll: labels are ONE per_page=100 request
against the 5,000/hr core budget (not the 30/min search quota the probes share), so the
worst case is ~6 requests an hour per open repo, and a probe here would cost about as much as
the refetch it guards. Both providers already paginate the label fetch (--paginate on
GitHub, explicit page=N walking on GitLab), so a repo with more than 100 labels was never
the truncation; the cache was.
In-App Cross-References
An issue/PR body or comment that links to ANOTHER issue or PR in the connected
repo currently open does not leave the app: the click opens that target in a
bottom sheet (components/RefSheet.tsx) over the workspace, rendering the same
detail pane (IssueDetail / PrDetail) the right column uses. Everything else —
the list, the filters, the selected item — is untouched.
- Matching (
lib/refLinks.ts:parseRepoRef) is deliberately narrow. Only an absolutehttp(s)URL ongithub.com/www.github.comwhose path is/<owner>/<repo>/(issues|pull|pulls)/<positive int>and whose owner/repo match the ACTIVE repo (case-insensitively) is claimed. Trailing segments (/files), query strings and#issuecomment-…fragments are ignored — same target. Any other link (a different repo, an Enterprise host,/discussions/,/commit/, a relative href, a non-http(s)scheme) keeps its existing behaviour and opens externally. A repo is identified by owner/repo only, so a same-path URL on an Enterprise host is a DIFFERENT repo and is never claimed. - Interception happens at the ANCHOR, not on the DOM:
MarkdownRendererexposes aLinkOverrideCtxseam (a predicate-style render override consulted by its default anchor), andcomponents/RefMarkdown.tsxprovides one that returnscomponents/RefLink.tsxfor claimed hrefs. The markdown pipeline is otherwise untouched, nothing post-processes React-owned DOM, and links keep their href/target — so a modified click (Cmd/Ctrl/Shift/Alt), a middle click (which firesauxclick), and "copy link address" all still behave like GitHub links. Keyboard activation works because it dispatches the same click. - Shorthand.
lib/refLinks.ts:linkifyIssueRefsrewrites a bare#123into a real markdown link before rendering (the raw markdown the API returns carries only the literal text; GitHub's own web UI linkifies it at render time). Fenced code, inline code, autolinks, raw HTML and existing markdown links are masked out first. A shorthand is rejected when preceded by a word character,/(a URL fragment or a cross-repoowner/repo#5),&({),[,(or#, and when FOLLOWED by a word character (so#1a2b3cis not read as#1). An all-digit run is taken as a reference — GitHub does the same, and six-figure issue numbers are ordinary, so length cannot decide. - Affordance + preview. A claimed reference renders with a DASHED accent
underline (a solid one stays "ordinary external link"), and hovering or focusing
it opens a preview card — number, title, author, when, lifecycle — after a short
delay, fetched from
/refonly on demand. The card is portalled to<body>with fixed coordinates so nooverflow: hiddenancestor clips it, flips above the link near the viewport bottom, and is dismissed by scroll/resize (its position is captured at open time). - Kind resolution.
#123and/issues/123are both ambiguous, so the pane is chosen by/ref'sis_pr, not by the link's shape. An explicit/pull/link renders immediately; a failed lookup degrades to the issue pane rather than blocking. The lookup shares its query key with the hover card, so opening a reference you hovered costs nothing. - Stack.
refStackin the context holds the open trail, innermost last. A reference followed from inside the sheet pushes; Escape and the header's back control pop; the backdrop and the close button discard the whole trail. It is transient (never persisted) and is cleared on a repo switch, because a bare number means nothing across repos. - Presentation. The sheet is bottom-ANCHORED with square bottom corners, so it reads as growing out of the page rather than as a card sitting low. It takes ~94%/93% of the app area (px-capped only on very large displays) — most of the space, because a detail pane is a two-column layout with a 236px sidebar, but never all of it: the workspace visible around the edges is what says "detour, not navigation".
- Data path.
GET /issueandGET /pullalready fetch any number on demand for a connected repo; only the cheap/refsummary is new. When the target is in the loaded list its row seeds the first paint (and the sheet offers "open in the workspace", which promotes it to the main selection); otherwise a placeholder row carries the number until the detail arrives. Both panes therefore readdetail?.x ?? row.xfor the title, the GitHub URL and the poll lifecycle.
Platform Requirements
- GitHub and GitLab work on macOS, Linux and Windows. The provider-CLI trust
check is answered from POSIX ownership (
st_uid+ the group/other write bits) or, on Windows, from the object's ACL — seegithub_runner.check_provider_path_component_windowsandkiro_crew.windows_acl. An elevated Windows gateway is refused for the same reason a root POSIX one is: its children would be elevated too, which makes the ownership walk vacuous. - Azure DevOps is POSIX only (macOS/Linux).
azure_client._az_binrefuseswin32before it resolves anything, and raisesProviderCliErrorrather thanProviderSetupErrorso the connect dialog does not offer an install that would not help. This is a scope statement, not a platform limit inherited from the other two: nothing here has been exercised againstazon Windows, and the shared candidate table carries no well-known-directory entries foraz, so the only Windows resolution path would be the untested override. Use WSL to run the Kiro Crew gateway against Azure DevOps. - An authenticated CLI for each provider you actually connect:
gh,glab, orazwith theazure-devopsextension (az extension add --name azure-devops) and anaz loginsession orAZURE_DEVOPS_EXT_PAT. None of the three is a hard dependency —app.jsonlists all of them underdependencies.optionalCommands, so a shop using one provider installs without the other two. A missing CLI is aProviderSetupErroron the first call to that provider (reason: "not_installed"), not an install-time refusal. - Any CLI the user can run from their terminal is accepted: the well-known dirs
(
/opt/homebrew/bin,/usr/local/bin,/usr/bin,/home/linuxbrew/…, the managedlibexec/kirocrewdirs, and on Windows theGitHub CLIsubdirectory of each Program Files root) are searched first, thenPATH. Nosudocopy is required. Override withKIROCREW_ISSUE_RADAR_GH/KIROCREW_ISSUE_RADAR_GLAB/KIROCREW_ISSUE_RADAR_AZ; harden withKIROCREW_PROVIDER_BIN_STRICT=1. All three executables carry the same well-known-directory entries (github_runner.PROVIDER_EXECUTABLE_CANDIDATES), so strict mode resolves a packaged/usr/bin/azexactly as it doesgh; what strict mode hides is any install reachable only throughPATH, which is what the override is for. - Reachable hosts:
github.com,dev.azure.com,gitlab.com, and any self-managed GitLab in the operator'sdashboard.gitlab_hostsallowlist. GitHub Enterprise Server and Azure DevOps Server (on-premises) are not supported — see Providers for why the CLI-passthrough transport cannot serve them.