You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of milestone #6 (Admin global session observability).
Background
fkst-hosted is DB-free: the controller only knows the goals it created itself, held in an in-memory GoalIssueStore (backend/fkst-control-plane/src/goals/issue_store.rs, the GoalIssueStore struct at issue_store.rs:287) with a GitHub-Issue mirror; there is no reconcile-from-GitHub path and no registry of connected repos or users. GROUND: a platform-wide admin view cannot be built from local state alone — it must enumerate every repo the GitHub App is installed on and read its goal issues.
The GitHub App layer already mints per-repo installation tokens. backend/fkst-control-plane/src/github_app/api.rs defines trait GithubApi (api.rs:73-93) with exactly two endpoints — installation_for_repo (GET {api_base}/repos/{owner}/{repo}/installation, app-JWT bearer, api.rs:165-214) and create_installation_token (POST {api_base}/app/installations/{id}/access_tokens, api.rs:216-288). GROUND (verified): the App can resolve a known repo and mint a token, but there is no way to discover the set of installs/repos — neither GET /app/installations nor GET /installation/repositories is implemented anywhere in src/ (the only app/installations string in the codebase is the …/access_tokens mint).
Token permissions already include issues read. default_permissions() (github_app/mod.rs:164) requests issues: write (a superset of read) per #110, confirmed by the token_mint_serializes_admin_and_pull_requests test (api.rs:396-440) asserting "issues": "write" in the request body. GROUND: reading fkst-goal issues across discovered repos needs no new App permission and no repo visibility change — repos stay private.
The app-JWT bearer and reqwest transport patterns are reusable as-is. github_app/jwt.rs:29 (build_encoding_key) and jwt.rs:38 (mint_app_jwt) build the short-lived app JWT; HttpGithubApi::new (api.rs:109-121) builds the 20s-timeout client with the literal fkst-hosted-api user-agent string; reset_seconds (api.rs:128-148) and is_rate_limited (api.rs:154-161) are pub(super) rate-limit helpers already shared with the #179 Contents reader (github_app/contents.rs). GROUND: the new endpoints plug into the existing GithubApi trait + HttpGithubApi impl, reusing the same auth, timeout, 401/403/rate-limit classification, and wiremock test harness already present in api.rs (tests at api.rs:291-656).
Resilient per-unit fan-out already has a reference shape. github_hub/fanout.rs:117 (aggregate_issues) spawns one JoinSet task per account, never fails the whole request on a single unit's failure, folds per-unit failures into structured results (AccountError at github_hub/types.rs:63), and uses a case-insensitive login filter (filter_accounts at fanout.rs:169, eq_ignore_ascii_case). GROUND: per-installation repo-list failure must be a partial result, not a total abort — mirror this pattern.
Purpose
Give the GitHub App an authoritative, registry-free way to discover all connected repositories: list every App installation, list each installation's repositories, and expose a de-duplicated connected_repos set (optionally scoped to a set of org logins by the caller). This is the discovery substrate the sibling milestone-#6 "global cross-repo issue aggregation reader" issue consumes to read fkst-goal issues platform-wide for the admin view. Closes gap H1.
Reuses the resilient fan-out shape from github_hub/fanout.rs (aggregate_issues / AccountError / filter_accounts).
Affected Files
modifybackend/fkst-control-plane/src/github_app/api.rs — add InstallationSummary, ConnectedRepo types; add list_installations and list_installation_repositories to trait GithubApi + HttpGithubApi; pagination + per-page parsing; wiremock tests in the existing mod tests.
modifybackend/fkst-control-plane/src/github_app/mod.rs — add the connected_repos(scope_orgs) convenience method on GithubAppTokens (mints the fresh app JWT via mint_app_jwt/build_encoding_key, optionally filters by org login, lists each installation's repos, de-dupes, folds per-installation failures); extend the existing pub use api::{InstallationId, InstallationToken, TokenPermissions}; (mod.rs:39) and add the new mod.rs-local result types.
reference onlybackend/fkst-control-plane/src/github_hub/fanout.rs, github_hub/types.rs (AccountError, filter_accounts) — resilient partial-failure + case-insensitive-filter pattern to mirror; do not modify.
reference onlybackend/fkst-shared/src/models/mod.rs (RepoRef { owner, name }, the canonical type) and its re-export crate::goals::model::RepoRef — reuse the existing repo reference type; do not redefine it.
NOTE: never use the stale path backend/fkst-hosted-api; the crate is backend/fkst-control-plane. fkst-hosted-api survives only as the HTTP user-agentstring literal in the transport, which is intentionally left unchanged.
Implementation Instructions
Each numbered item is one small, atomic, independently buildable commit. RepoRef is crate::models::RepoRef (re-exported as crate::goals::model::RepoRef, already used by goals/marker.rs:19) and its fields are owner: String and name: String — NOT repo. Reuse it; do not introduce a parallel type.
Add the result types in github_app/api.rs. Define:
GET {api_base}/app/installations with app-JWT bearer (NOT an installation token), accept: application/vnd.github+json. Paginate per_page=100: follow the Link header rel="next" URL when present; otherwise stop. (A page-counter loop that stops on an empty array is acceptable as a fallback only if Link is absent.)
Map each item: id ← id (u64), account_login ← account.login, account_type ← account.type, repository_selection ← repository_selection. A malformed item (missing required field) is a GithubAppError::Http(...) for that request.
Reuse the existing status disambiguation exactly as installation_for_repo: 401 → AppAuth; 403 → RateLimited(reset_seconds(headers)) when is_rate_limited(headers) else AppAuth; other non-2xx → Http(...). On 403 rate-limit, honor Retry-After/x-ratelimit-reset via reset_seconds; retry the same page a bounded number of times (e.g. up to 3) with a single sleep of the reset window, then surface RateLimited if still limited.
Verify with the wiremock test (step 6) cargo test -p fkst-control-plane github_app::api::tests::list_installations → passes.
Add list_installation_repositories to trait GithubApi + HttpGithubApi in github_app/api.rs:
Mint an installation token for id via the existing create_installation_token path with InstallationTokenRequest { repositories: vec![], permissions: None } (no repo restriction, the installation default applies — metadata/issues read suffices to list repos). Then GET {api_base}/installation/repositories with that installation token as bearer, per_page=100, paginated via Link rel=next (the response wraps repos under a repositories key, so the page-counter fallback keys off an empty repositories array).
Response shape: { total_count, repositories: [{ owner: { login }, name, private }] }. Map each into ConnectedRepo { repo: RepoRef { owner: owner.login, name }, installation_id: id, account_login: owner.login, account_type: <unknown here; carry "" or the summary's value via the caller — see step 4>, private }. Capture private (observability only; it MUST NOT gate anything).
Same 401/403/rate-limit/bounded-retry handling as step 2, using the installation token (not the app JWT) for the GET /installation/repositories call.
Add a // why: comment that account_type/account_login are authoritative from the installation summary, not this per-repo response (the owner login matches, but the account type field is absent here).
Verify: cargo test -p fkst-control-plane github_app::api::tests::list_installation_repositories → passes.
Add connected_repos convenience in github_app/mod.rs, an impl GithubAppTokens method:
Signature: pub async fn connected_repos(&self, scope_orgs: &[String]) -> Result<ConnectedReposResult, GithubAppError> where ConnectedReposResult { pub repos: Vec<ConnectedRepo>, pub errors: Vec<InstallationError> } and InstallationError { pub installation_id: InstallationId, pub account_login: String, pub message: String } (credential-free message only). Err is reserved for the whole-enumeration failure (see below).
Mint a fresh app JWT per call (mint_app_jwt(self.inner.app_id, &self.inner.encoding_key)) — the app JWT is short-lived.
Call list_installations. Filter: when scope_orgs is non-empty, keep an installation only if account_type == "Organization" and account_login matches any scope_orgs entry case-insensitively (eq_ignore_ascii_case); empty scope_orgs ⇒ keep all. (Mirror filter_accounts in fanout.rs.)
For each kept installation, call list_installation_repositories. Overwrite each returned ConnectedRepo.account_type/account_login with the installation summary's authoritative values. A per-installation failure is folded into errors (with installation_id + account_login + the error's Display message) and MUST NOT abort the others — mirror aggregate_issues' "never fail the whole request" contract.
De-dupe the final repos by (account_login, repo.name) case-insensitively (a repo can appear once; selected-vs-all installs cannot overlap, but de-dupe keeps the result total).
If list_installations itself fails (the only call that can fail the whole enumeration), return that as Err to the caller (analogous to proxy.accounts().await? short-circuiting in aggregate_issues).
Add tracing at debug for counts (installations found, kept after scope, repos discovered) and warn per folded per-installation error — never log tokens.
Verify: cargo test -p fkst-control-plane github_app → passes.
Re-export the public types in github_app/mod.rs: extend the existing pub use api::{InstallationId, InstallationToken, TokenPermissions}; (mod.rs:39) to also export InstallationSummary, ConnectedRepo, and export ConnectedReposResult, InstallationError (defined in mod.rs) at the module root.
Tests (in github_app::api::tests and a mod.rs test module, mirroring the existing wiremock style in api.rs:291-656 and the FakeApi transport fake at mod.rs:670-748):
list_installations paginated: mount /app/installations returning 2 pages via Link: <…?page=2>; rel="next" then a final page with no next; assert both pages' summaries are returned, app-JWT bearer header asserted, account type/repository_selection parsed.
list_installation_repositories: mount the token-mint POST /app/installations/{id}/access_tokens + GET /installation/repositories{ total_count, repositories: [...] }; assert the installation-token bearer is used on the repositories GET, private flag captured, RepoRef { owner, name } mapped.
connected_repos org-scope filter: two installations (one Organization, one User); scope_orgs=["AcmeOrg"] (mixed case) keeps only the matching org's repos.
connected_repos de-dupe: same (login, repo.name) from overlapping responses appears once.
connected_repos partial failure: one installation's repo-list returns 500/403 → its repos absent, an InstallationError entry present, the other installation's repos still returned (assert the 200-always-for-the-rest contract).
Pagination edge: empty final page / absent Link terminates the loop.
Verify: cargo test -p fkst-control-plane github_app → all green.
Constraints / Non-goals
Never make repos public / no visibility change. This feature reads private repos with the App installation token; do not add any visibility-mutation code (goals/repo_create.rs already creates private; leave it). ConnectedRepo.private is carried for observability only and gates nothing.
Never expose secrets. This issue handles tokens only; the installation/repo tokens are minted, used as bearers, never logged, never returned. The goal prompt and env/secret values are not in scope here at all — they live only in the engine, never in GitHub (the feat: represent goals as GitHub Issues (remove the goals collection) #137 marker carries owner/org/packages/repo, never the prompt).
App reads use the INSTALLATION token, never the user NyxID proxy. The existing github_hub aggregation reads the user's own connected accounts; this discovery (and its sibling reader) must use the App-installation path instead.
Never modify the kernel engine (fkst-substrate) — out of scope.
No admin route, no aggregation reader, no FKST_ADMIN_SCAN_ORGS env var here — those belong to the two sibling milestone-feat: Changeset + SemVer release pipeline (gates, tagging, CHANGELOG, release notes) #6 issues. connected_repos accepts scope_orgs as a caller-supplied slice; wiring it to config is the reader issue's job. This issue stops at discovery (connected_repos).
File-only spec: no implementation is performed by this issue; implementation is a separate PR.
Verification Checklist
cargo build -p fkst-control-plane compiles after each commit (atomic, buildable-per-commit).
cargo test -p fkst-control-plane github_app passes (pagination, org-scope filter, de-dupe, partial-failure, whole-enumeration failure, app-JWT vs installation-token bearers).
cargo clippy -p fkst-control-plane clean.
gitleaks clean; no token/secret logged or returned.
Definition of Done
list_installations, list_installation_repositories, and connected_repos(scope_orgs) implemented per spec, reusing the existing GithubApi/HttpGithubApi/jwt/rate-limit machinery and the RepoRef { owner, name } type.
Pagination (per_page=100 + Link rel=next), secondary-rate-limit handling (Retry-After/x-ratelimit-reset/reset_seconds, bounded retries), and per-installation partial-failure folding all implemented.
New tests added and green (cargo test -p fkst-control-plane github_app); cargo build + cargo clippy clean.
No kernel-engine (fkst-substrate) change; no repo visibility change; no secret/prompt exposed or logged; no persisted registry introduced.
Each commit is small, self-contained, and builds + tests green on its own.
No Co-Authored-By (or any co-author/AI trailer) in any commit.
A changeset is included (npx changeset, patch/minor as appropriate).
PR targets develop (or develop-auto), links this issue with Closes #N, and merges only on green CI.
Part of milestone #6 (Admin global session observability).
Background
fkst-hosted is DB-free: the controller only knows the goals it created itself, held in an in-memory
GoalIssueStore(backend/fkst-control-plane/src/goals/issue_store.rs, theGoalIssueStorestruct atissue_store.rs:287) with a GitHub-Issue mirror; there is no reconcile-from-GitHub path and no registry of connected repos or users. GROUND: a platform-wide admin view cannot be built from local state alone — it must enumerate every repo the GitHub App is installed on and read its goal issues.The GitHub App layer already mints per-repo installation tokens.
backend/fkst-control-plane/src/github_app/api.rsdefinestrait GithubApi(api.rs:73-93) with exactly two endpoints —installation_for_repo(GET {api_base}/repos/{owner}/{repo}/installation, app-JWT bearer,api.rs:165-214) andcreate_installation_token(POST {api_base}/app/installations/{id}/access_tokens,api.rs:216-288). GROUND (verified): the App can resolve a known repo and mint a token, but there is no way to discover the set of installs/repos — neitherGET /app/installationsnorGET /installation/repositoriesis implemented anywhere insrc/(the onlyapp/installationsstring in the codebase is the…/access_tokensmint).Token permissions already include issues read.
default_permissions()(github_app/mod.rs:164) requestsissues: write(a superset of read) per #110, confirmed by thetoken_mint_serializes_admin_and_pull_requeststest (api.rs:396-440) asserting"issues": "write"in the request body. GROUND: readingfkst-goalissues across discovered repos needs no new App permission and no repo visibility change — repos stay private.The app-JWT bearer and reqwest transport patterns are reusable as-is.
github_app/jwt.rs:29(build_encoding_key) andjwt.rs:38(mint_app_jwt) build the short-lived app JWT;HttpGithubApi::new(api.rs:109-121) builds the 20s-timeout client with the literalfkst-hosted-apiuser-agent string;reset_seconds(api.rs:128-148) andis_rate_limited(api.rs:154-161) arepub(super)rate-limit helpers already shared with the #179 Contents reader (github_app/contents.rs). GROUND: the new endpoints plug into the existingGithubApitrait +HttpGithubApiimpl, reusing the same auth, timeout, 401/403/rate-limit classification, andwiremocktest harness already present inapi.rs(tests atapi.rs:291-656).Resilient per-unit fan-out already has a reference shape.
github_hub/fanout.rs:117(aggregate_issues) spawns oneJoinSettask per account, never fails the whole request on a single unit's failure, folds per-unit failures into structured results (AccountErroratgithub_hub/types.rs:63), and uses a case-insensitive login filter (filter_accountsatfanout.rs:169,eq_ignore_ascii_case). GROUND: per-installation repo-list failure must be a partial result, not a total abort — mirror this pattern.Purpose
Give the GitHub App an authoritative, registry-free way to discover all connected repositories: list every App installation, list each installation's repositories, and expose a de-duplicated
connected_reposset (optionally scoped to a set of org logins by the caller). This is the discovery substrate the sibling milestone-#6 "global cross-repo issue aggregation reader" issue consumes to readfkst-goalissues platform-wide for the admin view. Closes gap H1.Relationships
issues:write) and on the existinggithub_apptoken/JWT machinery (api.rs,jwt.rs).fkst-hosted:goalbody marker,GoalMarkeringoals/marker.rs) and feat: session-lifecycle goal-issue labels + persisted terminal cause (user-stop vs graceful completion) #180 (thefkst-goal+ session-lifecycle labels ingoals/labels.rs) — but only the sibling reader issue parses those; this issue stops at repo discovery.fkst-goalissues via the App installation token (issues:read, never the user NyxID proxy) → parse the feat: represent goals as GitHub Issues (remove the goals collection) #137 marker + feat: session-lifecycle goal-issue labels + persisted terminal cause (user-stop vs graceful completion) #180 labels into a global session model, optionally scoped byFKST_ADMIN_SCAN_ORGS).fkst:admin-gated route + the livepid/runtime_dir/pod_id/fencing_tokenoverlay fromsessions/repo.rs).github_hub/fanout.rs(aggregate_issues/AccountError/filter_accounts).Affected Files
backend/fkst-control-plane/src/github_app/api.rs— addInstallationSummary,ConnectedRepotypes; addlist_installationsandlist_installation_repositoriestotrait GithubApi+HttpGithubApi; pagination + per-page parsing; wiremock tests in the existingmod tests.backend/fkst-control-plane/src/github_app/mod.rs— add theconnected_repos(scope_orgs)convenience method onGithubAppTokens(mints the fresh app JWT viamint_app_jwt/build_encoding_key, optionally filters by org login, lists each installation's repos, de-dupes, folds per-installation failures); extend the existingpub use api::{InstallationId, InstallationToken, TokenPermissions};(mod.rs:39) and add the newmod.rs-local result types.backend/fkst-control-plane/src/github_hub/fanout.rs,github_hub/types.rs(AccountError,filter_accounts) — resilient partial-failure + case-insensitive-filter pattern to mirror; do not modify.backend/fkst-shared/src/models/mod.rs(RepoRef { owner, name }, the canonical type) and its re-exportcrate::goals::model::RepoRef— reuse the existing repo reference type; do not redefine it.Implementation Instructions
Each numbered item is one small, atomic, independently buildable commit.
RepoRefiscrate::models::RepoRef(re-exported ascrate::goals::model::RepoRef, already used bygoals/marker.rs:19) and its fields areowner: Stringandname: String— NOTrepo. Reuse it; do not introduce a parallel type.Add the result types in
github_app/api.rs. Define:pub struct InstallationSummary { pub id: InstallationId, pub account_login: String, pub account_type: String /* "User" | "Organization" */, pub repository_selection: String /* "all" | "selected" */ }(deriveDebug, Clone, PartialEq, Eq).pub struct ConnectedRepo { pub repo: RepoRef, pub installation_id: InstallationId, pub account_login: String, pub account_type: String, pub private: bool }(deriveDebug, Clone, PartialEq, Eq).Add the
use crate::models::RepoRef;import. No behavior yet.cargo build -p fkst-control-plane→ compiles.Add
list_installationstotrait GithubApi+HttpGithubApiingithub_app/api.rs:async fn list_installations(&self, app_jwt: &SecretString) -> Result<Vec<InstallationSummary>, GithubAppError>.GET {api_base}/app/installationswith app-JWT bearer (NOT an installation token),accept: application/vnd.github+json. Paginateper_page=100: follow theLinkheaderrel="next"URL when present; otherwise stop. (A page-counter loop that stops on an empty array is acceptable as a fallback only ifLinkis absent.)id←id(u64),account_login←account.login,account_type←account.type,repository_selection←repository_selection. A malformed item (missing required field) is aGithubAppError::Http(...)for that request.installation_for_repo: 401 →AppAuth; 403 →RateLimited(reset_seconds(headers))whenis_rate_limited(headers)elseAppAuth; other non-2xx →Http(...). On 403 rate-limit, honorRetry-After/x-ratelimit-resetviareset_seconds; retry the same page a bounded number of times (e.g. up to 3) with a single sleep of the reset window, then surfaceRateLimitedif still limited.cargo test -p fkst-control-plane github_app::api::tests::list_installations→ passes.Add
list_installation_repositoriestotrait GithubApi+HttpGithubApiingithub_app/api.rs:async fn list_installation_repositories(&self, app_jwt: &SecretString, id: InstallationId) -> Result<Vec<ConnectedRepo>, GithubAppError>.idvia the existingcreate_installation_tokenpath withInstallationTokenRequest { repositories: vec![], permissions: None }(no repo restriction, the installation default applies —metadata/issuesread suffices to list repos). ThenGET {api_base}/installation/repositorieswith that installation token as bearer,per_page=100, paginated viaLink rel=next(the response wraps repos under arepositorieskey, so the page-counter fallback keys off an emptyrepositoriesarray).{ total_count, repositories: [{ owner: { login }, name, private }] }. Map each intoConnectedRepo { repo: RepoRef { owner: owner.login, name }, installation_id: id, account_login: owner.login, account_type: <unknown here; carry "" or the summary's value via the caller — see step 4>, private }. Captureprivate(observability only; it MUST NOT gate anything).GET /installation/repositoriescall.// why:comment thataccount_type/account_loginare authoritative from the installation summary, not this per-repo response (the owner login matches, but the accounttypefield is absent here).cargo test -p fkst-control-plane github_app::api::tests::list_installation_repositories→ passes.Add
connected_reposconvenience ingithub_app/mod.rs, animpl GithubAppTokensmethod:pub async fn connected_repos(&self, scope_orgs: &[String]) -> Result<ConnectedReposResult, GithubAppError>whereConnectedReposResult { pub repos: Vec<ConnectedRepo>, pub errors: Vec<InstallationError> }andInstallationError { pub installation_id: InstallationId, pub account_login: String, pub message: String }(credential-free message only).Erris reserved for the whole-enumeration failure (see below).mint_app_jwt(self.inner.app_id, &self.inner.encoding_key)) — the app JWT is short-lived.list_installations. Filter: whenscope_orgsis non-empty, keep an installation only ifaccount_type == "Organization"andaccount_loginmatches anyscope_orgsentry case-insensitively (eq_ignore_ascii_case); emptyscope_orgs⇒ keep all. (Mirrorfilter_accountsinfanout.rs.)list_installation_repositories. Overwrite each returnedConnectedRepo.account_type/account_loginwith the installation summary's authoritative values. A per-installation failure is folded intoerrors(withinstallation_id+account_login+ the error'sDisplaymessage) and MUST NOT abort the others — mirroraggregate_issues' "never fail the whole request" contract.reposby(account_login, repo.name)case-insensitively (a repo can appear once; selected-vs-all installs cannot overlap, but de-dupe keeps the result total).list_installationsitself fails (the only call that can fail the whole enumeration), return that asErrto the caller (analogous toproxy.accounts().await?short-circuiting inaggregate_issues).tracingatdebugfor counts (installations found, kept after scope, repos discovered) andwarnper folded per-installation error — never log tokens.cargo test -p fkst-control-plane github_app→ passes.Re-export the public types in
github_app/mod.rs: extend the existingpub use api::{InstallationId, InstallationToken, TokenPermissions};(mod.rs:39) to also exportInstallationSummary, ConnectedRepo, and exportConnectedReposResult, InstallationError(defined inmod.rs) at the module root.cargo build -p fkst-control-plane→ compiles.Tests (in
github_app::api::testsand amod.rstest module, mirroring the existing wiremock style inapi.rs:291-656and theFakeApitransport fake atmod.rs:670-748):list_installationspaginated: mount/app/installationsreturning 2 pages viaLink: <…?page=2>; rel="next"then a final page with nonext; assert both pages' summaries are returned, app-JWT bearer header asserted, accounttype/repository_selectionparsed.list_installation_repositories: mount the token-mintPOST /app/installations/{id}/access_tokens+GET /installation/repositories{ total_count, repositories: [...] }; assert the installation-token bearer is used on the repositories GET,privateflag captured,RepoRef { owner, name }mapped.connected_reposorg-scope filter: two installations (oneOrganization, oneUser);scope_orgs=["AcmeOrg"](mixed case) keeps only the matching org's repos.connected_reposde-dupe: same(login, repo.name)from overlapping responses appears once.connected_repospartial failure: one installation's repo-list returns 500/403 → its repos absent, anInstallationErrorentry present, the other installation's repos still returned (assert the 200-always-for-the-rest contract).connected_reposwhole-enumeration failure:list_installations401 →connected_reposreturnsErr(AppAuth).Linkterminates the loop.cargo test -p fkst-control-plane github_app→ all green.Constraints / Non-goals
goals/repo_create.rsalready createsprivate; leave it).ConnectedRepo.privateis carried for observability only and gates nothing.issues:writegrant (chore: grant substrate session tokens administration:write (+ pull_requests) for the whole session #110); do not changedefault_permissions().github_hubaggregation reads the user's own connected accounts; this discovery (and its sibling reader) must use the App-installation path instead.fkst-substrate) — out of scope.FKST_ADMIN_SCAN_ORGSenv var here — those belong to the two sibling milestone-feat: Changeset + SemVer release pipeline (gates, tagging, CHANGELOG, release notes) #6 issues.connected_reposacceptsscope_orgsas a caller-supplied slice; wiring it to config is the reader issue's job. This issue stops at discovery (connected_repos).Verification Checklist
cargo build -p fkst-control-planecompiles after each commit (atomic, buildable-per-commit).cargo test -p fkst-control-plane github_apppasses (pagination, org-scope filter, de-dupe, partial-failure, whole-enumeration failure, app-JWT vs installation-token bearers).cargo clippy -p fkst-control-planeclean.gitleaksclean; no token/secret logged or returned.Definition of Done
list_installations,list_installation_repositories, andconnected_repos(scope_orgs)implemented per spec, reusing the existingGithubApi/HttpGithubApi/jwt/rate-limit machinery and theRepoRef { owner, name }type.per_page=100+Link rel=next), secondary-rate-limit handling (Retry-After/x-ratelimit-reset/reset_seconds, bounded retries), and per-installation partial-failure folding all implemented.cargo test -p fkst-control-plane github_app);cargo build+cargo clippyclean.fkst-substrate) change; no repo visibility change; no secret/prompt exposed or logged; no persisted registry introduced.Co-Authored-By(or any co-author/AI trailer) in any commit.npx changeset,patch/minoras appropriate).develop(ordevelop-auto), links this issue withCloses #N, and merges only on green CI.