neosourceDocs
Search docs

Change a repository's GitHub relationship

POST/api/repos/{owner}/{repo}/github-relationship

updateGithubRelationship

Moves the repository's relationship under a **compare-and-swap**: `expected_mode` and `expected_authority_epoch` must both match the stored row, or the request is refused with `409 relationship_conflict` and nothing is written. `expected_mode: null` with `expected_authority_epoch: 0` is how a caller says "this repository has no relationship yet" — which is a different precondition from a row that happens to sit at epoch 0. The demotion re-checks the precondition inside its own write, so a demotion racing another demotion is refused rather than lost. The arming edges check it against the row the request read — which is the whole guarantee this build makes, and enough of one, because `neosource_readonly_mirror` is the only relationship any writer in this build can establish, so an arm has nothing to race. Requires repository **admin**, and — for the arming edges only — a linked GitHub account with a stored access token, since that is the credential the metadata backfill and the recurring polls run on. The demotion never requires one: it is the kill switch, and a repository whose importer lost their GitHub connection is exactly the case that needs it. Omitting `expected_authority_epoch` is refused by body validation rather than read as an implicit `0`: a defaulted precondition is a compare-and-swap that silently is not one. A negative value is `400 invalid_input`. Three edges are always admitted, all of them landing on `neosource_readonly_mirror`: arming the relationship on a repository that has none, an idempotent re-arm (which does **not** move the epoch, leaves the existing importer in place, and doubles as the recovery surface for a sync parked in `error`), and the demotion of a `github_managed` repository — ADR 0115's "stop accepting writes immediately" kill switch, an edge that bumps `authority_epoch` and so invalidates any provider lease registered under the old one. A deployment that **offers** the `github_managed` relationship admits two more: the promotion of an armed `neosource_readonly_mirror` (epoch +1, and the edge that opens provider admission) and an idempotent re-assert of a repository already in it (which does **not** move the epoch, so an outstanding provider lease survives it). Both additionally require the repository to carry a GitHub App installation whose grant is neither revoked nor too narrow, and a `write_health` that is not `lfs_blocked` — a repository that fails one of those is refused with `409 relationship_conflict` and a message naming the state to fix. A deployment that does not offer the relationship refuses both edges with `409 relationship_unavailable`. Everything else is `409 relationship_unavailable`, which is **never** retryable: `neosource_managed` and `github_backup` belong to later phases, a repository with no relationship row cannot go straight to `github_managed` (arm it first), and a repository this deployment is already authoritative for never goes back to GitHub. No `Idempotency-Key`: the compare-and-swap is the idempotency, since a replay carries a now-stale epoch.

Requires authentication using a bearer token or a session cookie — see tokens and scopes.

curl

curl -X POST 'https://neosource.dev/api/repos/OWNER/REPO/github-relationship' \
  -H 'Authorization: Bearer $NEOSOURCE_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"expected_authority_epoch":0,"mode":"string"}'

fetch

fetch("https://neosource.dev/api/repos/OWNER/REPO/github-relationship", {
  method: "POST",
  headers: {
    Authorization: "Bearer $NEOSOURCE_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"expected_authority_epoch":0,"mode":"string"}),
});

Path parameters

ownerrequired

Repository owner or organization slug.

string

reporequired

Repository name.

string

Request bodyrequired

application/json

UpdateGithubRelationshipRequest

object

Request body of `POST /api/repos/{owner}/{repo}/github-relationship`. The `(expected_mode, expected_authority_epoch)` PAIR is a full compare-and-swap, and it takes both halves because neither alone is enough: the epoch distinguishes two states of one mode, and `expected_mode: null` (with epoch `0`) is what distinguishes "this repository has no relationship row" from "it has one, sitting at epoch 0". `deny_unknown_fields`, and `expected_authority_epoch` carries **no** `#[serde(default)]`: omitting it is refused rather than read as an implicit `0`, because a defaulted precondition is a compare-and-swap that silently is not one. Serde is what refuses it, so the status is axum's `Json` data-error `422` — the same one an unknown enum variant gets across this API. A value that parses but is negative is refused by `AuthorityEpoch::new` instead, as `400 invalid_input`. There is no `Idempotency-Key` here and none is needed (that is Phase 6): a compare-and-swap IS idempotency by construction — a replay carries a now-stale epoch and answers `409 relationship_conflict`.

expected_authority_epochrequired

integerint64

The `authority_epoch` the caller read. Required.

expected_mode
one of
  • null

  • GithubRelationshipMode

    string

    Which forge owns an imported repository's refs and provider-shaped data. The ONLY relationship vocabulary, in the database (`github_repo_sync.mode`), in Rust, on the wire and in the SPA. The legacy `synced` / `mirror_readonly` pair it replaced was collapsed onto [`Self::NeosourceReadonlyMirror`] by `migrations/20260902120000_github_relationship_contract.sql`.

    "github_managed""neosource_readonly_mirror""neosource_managed""github_backup"

moderequired

GithubRelationshipMode

string

Which forge owns an imported repository's refs and provider-shaped data. The ONLY relationship vocabulary, in the database (`github_repo_sync.mode`), in Rust, on the wire and in the SPA. The legacy `synced` / `mirror_readonly` pair it replaced was collapsed onto [`Self::NeosourceReadonlyMirror`] by `migrations/20260902120000_github_relationship_contract.sql`.

"github_managed""neosource_readonly_mirror""neosource_managed""github_backup"

additional propertiesnot allowed

Responses

200The repository's relationship, after the change

application/json

GithubRelationshipResponse

object

A repository's GitHub relationship (ADR 0115 Phase 4a) — the response body of both `GET` and `POST /api/repos/{owner}/{repo}/github-relationship`. Every field comes from a column `GithubRepoSyncRecord` already selects (or, for `github_is_authority` and `upstream_url`, is derived from one), plus the one `github_meta_jobs` lookup the legacy status route already makes: no new store read, and no field that is structurally always `None`. `issues_done` / `issues_total` are carried deliberately even though they are backfill progress rather than relationship state. With them this response **strictly supersedes** [`GithubSyncStatusResponse`], which is the precondition for Phase 4c being a deletion of the legacy pair rather than a redesign of this one. Reserved to later phases and deliberately ABSENT — each would be a field that is always its default until the phase that fills it lands, and a client cannot tell "not shipped" from "nothing to report": `paused_refs` (8), the LFS digest / checked-at / scanner version (5 — `write_health` already carries that verdict), `native_actions_mode` (7), `cutover_state` (9), and the App's permissions and health, which `GET …/github-installation` already serves — one statement per fact.

authority_epochrequired

integerint64

Authority generation. The `expected_authority_epoch` a subsequent `POST` must echo back, and what invalidates a provider projection lease registered under an older one.

full_namerequired

string

GitHub `owner/name` at last observation.

github_is_authorityrequired

boolean

Whether `mode` is one of the GitHub-authoritative pair. Derived from `mode` and sent anyway, so the SPA never restates the rule — the two would then be two statements of one fact with nothing comparing them.

issues_donerequired

integerint32

Issues upserted by the latest backfill run; `0` when none has run.

issues_totalrequired

integerint32

Upper bound for the backfill progress bar; `0` when unknown.

last_error

string | null

Most recent sync/backfill error, if any.

last_synced_at

integer | nullint64

Millis since the Unix epoch of the last successful poll/page.

moderequired

GithubRelationshipMode

string

Which forge owns an imported repository's refs and provider-shaped data. The ONLY relationship vocabulary, in the database (`github_repo_sync.mode`), in Rust, on the wire and in the SPA. The legacy `synced` / `mirror_readonly` pair it replaced was collapsed onto [`Self::NeosourceReadonlyMirror`] by `migrations/20260902120000_github_relationship_contract.sql`.

"github_managed""neosource_readonly_mirror""neosource_managed""github_backup"

provider_admission_openrequired

boolean

Whether provider projections may be admitted at all. `false` is the default and the parked state.

sync_staterequired

GithubSyncState

string

Lifecycle of a repo's metadata sync (`github_repo_sync.state`).

"backfilling""live""error"

upstream_urlrequired

string

The repository's page on github.com, so the SPA can link out without hard-coding the provider's host.

write_healthrequired

GithubWriteHealth

string

Whether a GitHub-authoritative repository is currently safe to write through — the `github_repo_sync.write_health` column. Closed vocabulary; the tokens are the CHECK list at `migrations/20260901120000_github_relationship_expand.sql`. The default for a fresh relationship is [`Self::LfsUnverified`], not `Healthy`: a repo whose LFS posture nobody has looked at yet is not known-good, and the fail-closed direction is the one that does not push objects at an upstream that will reject or mangle them.

"healthy""lfs_unverified""lfs_blocked""paused""error"

Standard errors

Bodies documented once for the whole API — see standard errors.

  • 400Bad Request — one of: invalid_input
  • 401Authentication required
  • 403Forbidden — one of: forbidden
  • 404Not Found — one of: not_found
  • 409Conflict — one of: conflict, relationship_conflict, relationship_unavailable
  • 429Rate limited — retry after the `Retry-After` header
  • 500Internal server error
  • 503Service temporarily unavailable / at capacity — retry after the `Retry-After` header
  • 504Gateway timeout — the request exceeded the server's handling budget