Skip to content

feat(github-app): enumerate connected installations + repos (connected_repos discovery) #210

@chronoai-shining

Description

@chronoai-shining

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.

Relationships

Affected Files

  • modify backend/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.
  • modify backend/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 only 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.
  • reference only backend/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-agent string 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.

  1. 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" */ } (derive Debug, Clone, PartialEq, Eq).
    • pub struct ConnectedRepo { pub repo: RepoRef, pub installation_id: InstallationId, pub account_login: String, pub account_type: String, pub private: bool } (derive Debug, Clone, PartialEq, Eq).
      Add the use crate::models::RepoRef; import. No behavior yet.
    • Verify: cargo build -p fkst-control-plane → compiles.
  2. Add list_installations to trait GithubApi + HttpGithubApi in github_app/api.rs:

    • Signature: async fn list_installations(&self, app_jwt: &SecretString) -> Result<Vec<InstallationSummary>, GithubAppError>.
    • 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: idid (u64), account_loginaccount.login, account_typeaccount.type, repository_selectionrepository_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.
  3. Add list_installation_repositories to trait GithubApi + HttpGithubApi in github_app/api.rs:

    • Signature: async fn list_installation_repositories(&self, app_jwt: &SecretString, id: InstallationId) -> Result<Vec<ConnectedRepo>, GithubAppError>.
    • 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.
  4. 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.
  5. 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.

    • Verify: cargo build -p fkst-control-plane → compiles.
  6. 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).
    • connected_repos whole-enumeration failure: list_installations 401 → connected_repos returns Err(AppAuth).
    • 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).
  • No registry. Discovery is purely from the GitHub App API; do not introduce any persisted store of installations/repos/users (feat: stateless GitHub App installation resolution (remove github_installations) #141 is stateless and stays that way).
  • No new App permission. Reuse the existing issues-read-via-issues:write grant (chore: grant substrate session tokens administration:write (+ pull_requests) for the whole session #110); do not change default_permissions().
  • 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.

Metadata

Metadata

Labels

backendRust/Axum backend workengine-integrationEncapsulating/invoking the fkst-substrate enginepriority:P2Normal. Default priority.size:MMedium: a few files, one concern. Size is informational.status:readyFully specced (size + priority + acceptance criteria). Ready to implement.type:featureNew user-facing capability.

Type

No type
No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions