Docs
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. On an **attached** repository that keeps its own issues (`metadata_mode: off`) the kill switch does not demote — a mirror would pull GitHub's issues over the native ones — it **pauses** instead: the relationship stays `github_managed` at the same `authority_epoch`, with `write_health: paused`, `provider_admission_open: false` and the git pull disarmed, and pushes are refused with a "GitHub sync is paused" error. `POST …/github-detach` turns sync off for good. Arming never discards local work: on a repository whose branches hold commits no upstream branch or tag is proven to contain (a local-only, ahead or diverged branch), or that holds an annotated tag the upstream does not have, the first git pull is refused and nothing moves — `git_last_error` then starts with `github_sync_would_discard_refs`, gives the exact count and names each ref with its local and upstream tip, in refname order (up to 500 refs or 64 KiB). To sync, fetch the repository from neosource and push each listed tip to GitHub — a branch under any branch name (`git push <github-remote> <local-oid>:refs/heads/neosource-rescue/<name>`), a tag as a tag (`git push <github-remote> <local-oid>:refs/tags/<name>`) — then re-arm; or turn on GitHub sync (`POST …/github-attach`), which reconciles them. A deployment that **offers** the `github_managed` relationship admits one more: an idempotent re-assert of a repository already in it (which does **not** move the epoch, so an outstanding provider lease survives it), and only for a repository carrying a GitHub App installation whose grant is neither revoked nor too narrow and a `write_health` that is not `lfs_blocked` — one that fails either is refused with `409 relationship_conflict` and a message naming the state to fix. Every other edge **into** `github_managed` (no relationship row, an armed `neosource_readonly_mirror`, a detached `neosource_managed` repository) is refused there with `409 relationship_conflict` pointing at `POST /api/repos/{owner}/{repo}/github-attach`, which previews and reconciles every ref before GitHub takes authority. A deployment that does not offer the relationship refuses every edge into it with `409 relationship_unavailable`. Everything else is `409 relationship_unavailable`, which is **never** retryable: `neosource_managed` and `github_backup` belong to later phases, and a repository this deployment is already authoritative for never goes back to GitHub through this route. 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
  • 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"

  • null

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. `git_last_error` keeps the pull's own error text (#1092 documents the `github_sync_would_discard_refs` refusal it carries). 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.

attach
one of
  • GithubAttachSummary

    object

    The latest attach, as the relationship read summarises it.

    created_atrequired

    integerint64

    Millis since the Unix epoch.

    finished_at

    integer | nullint64

    When the run reached a terminal state; `null` while in flight.

    last_error

    string | null

    originrequired
    GithubAttachOrigin
    run_idrequired

    string

  • null

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.

authority_handoff
one of
  • GithubAuthorityHandoff

    string

    How a `neosource_managed` relationship left GitHub's authority — the `github_repo_sync.authority_handoff` column. NULL on every other mode (a CHECK), and on `neosource_managed` rows that predate the column.

    "detached""cut_over"

  • null

blockersrequired

array

Everything that currently stops GitHub sync for this repository, in a stable order, each with the fix the panel renders. Empty means nothing at the repository level stops it.

items

GithubSyncBlockerRow

One thing that stops GitHub sync: the blocker itself — its `kind` plus whatever detail that kind carries (`outcome`, `health`, `refname`, `missing_events`) — the stable `fix` code the panel renders, and a link to where it is fixed when there is one.

all of
  • GithubSyncBlocker
  • object

    fixrequired
    GithubSyncFix
    fix_url

    string | null

    Where the fix is made: the GitHub App's install page for the App-side fixes, the GitHub repository's settings for an archived repository. `null` otherwise, and when this deployment has no App.

full_namerequired

string

GitHub `owner/name` at last observation.

git_consecutive_failures

integer | nullint32

Ticks that have failed in a row; `0` after any success. At `GITHUB_SYNC_MAX_CONSECUTIVE_FAILURES` (10) the row is **parked**: the poller's claim no longer returns it, `git_next_run_at` keeps its (past) timestamp, and only a re-arm — the relationship edge, the metadata-sync toggle, the import-complete hook — resets the budget. Below that it is lagging, not parked: the next attempt is at `git_next_run_at`, on the shared exponential backoff. `null` when the relationship carries no pull-side status.

git_last_error

string | null

Why the most recent tick failed, if it did; cleared by the next success. Never carries a credential. A message starting with `github_sync_would_discard_refs` is the first pull refusing to overwrite or delete local branches or tags no upstream branch or tag is proven to contain; it lists each ref and the remedy, and nothing moved.

git_last_pulled_at

integer | nullint64

Millis since the Unix epoch of the last tick that completed — an unchanged upstream counts, so this is "last checked", not "last changed". `null` until the first tick.

git_next_run_at

integer | nullint64

When the git pull-back next looks at the upstream, millis since the Unix epoch. `null` = **not armed**: nothing pulls this repository's refs from GitHub (the kill switch, and every row whose mode is neosource-authoritative). A timestamp in the past = due, waiting for a poller tick. Armed only on a GitHub-authoritative row — a `neosource_readonly_mirror`, which the pull follows, or a `github_managed` repository, which it observes — by the relationship edge into either mode, the metadata-sync toggle and the import-complete hook. A row that has exhausted its failure budget keeps its timestamp — see `git_consecutive_failures` for the parked state.

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.

metadata_moderequired

GithubMetadataMode

string

Whether GitHub's issues and pull requests are pulled into this repository — the `github_repo_sync.metadata_mode` column (`migrations/20260930170000_github_attach_runs.sql`). [`Self::Pulled`] is every relationship that predates the column and every read-only mirror (a CHECK). [`Self::Off`] is a repository attached to a GitHub repository while keeping its native issues (ADR 0136): its metadata is never claimed by the poller or the backfill.

"pulled""off"

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, github_app_install_required
  • 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

Schemas

Referenced above. Listed here rather than expanded inline, so the same definition is not repeated at every level.

GithubAttachOrigin

string

Who started an attach — `github_attach_runs.origin`.

"import""user"

GithubAttachRunState

string

Lifecycle of one attach attempt — `github_attach_runs.state`. ```text planning ─► planned ─(confirm)─► frozen ─► publishing ─► applying ─► applied ▲ │ │ │ └── needs_reconfirm ◄──(undo)───┴───────────┘ (+ failed, cancelled, expired) ``` The six nonterminal states are exactly the partial unique index `github_attach_runs_one_nonterminal_per_repo`, so a repository has at most one attach in flight.

"planning""planned""frozen""publishing""applying""applied""needs_reconfirm""failed""cancelled""expired"

GithubSyncBlocker

Everything that stops GitHub sync, enabling or ongoing: closed and typed, so the GitHub panel can name a fix for each. Each variant mirrors an existing core outcome rather than restating it: the binding ones are [`GithubAppBindingHealth`] values, `RulesetAudit` / `ProtectionEventsNotSubscribed` / `AdministrationReadMissing` are [`RulesetAuditOutcome`] refusals, `WritesRefused` is [`GithubWriteHealth::refuses_managed_writes`], the LFS ones are [`LfsVerificationResult`] verdicts. An empty or unrelated upstream is not a blocker (D11), nor is a tombstone (D7). Serialized internally tagged by `kind`, which is also [`Self::kind`].

one of
  • object

    The GitHub-sync offering is not enabled for this repository.

    kindrequired

    string

    "feature_not_offered"

  • object

    No App installation is bound to the repository.

    kindrequired

    string

    "no_app_binding"

  • object

    [`GithubAppBindingHealth::Unverified`], or `RulesetUnverified` with no refusing audit outcome recorded (awaiting a (re-)audit).

    kindrequired

    string

    "binding_unverified"

  • object

    [`GithubAppBindingHealth::Error`]: the binding probe failed.

    kindrequired

    string

    "binding_error"

  • object

    [`GithubAppBindingHealth::Revoked`].

    kindrequired

    string

    "binding_revoked"

  • object

    [`GithubAppBindingHealth::InsufficientScope`].

    kindrequired

    string

    "binding_insufficient_scope"

  • object

    The ruleset audit refused the binding for `outcome` ([`RulesetAuditOutcome::remedy`] is the fix). A struct variant because an internally tagged enum cannot carry a bare string newtype.

    kindrequired

    string

    "ruleset_audit"

    outcomerequired

    RulesetAuditOutcome

    string

    What the last ruleset audit of a binding found (`github_app_bindings.ruleset_audit_outcome`, migration `20260928120000_github_binding_ruleset_audit.sql`). [`Self::Clear`] is the one outcome that makes a binding `Healthy`; every other value records WHY it is `ruleset_unverified`. The audit fails closed: anything it cannot prove — a field GitHub did not report, a list it may not read, a rule whose ruleset it cannot see — is a refusal, never an assumed "the App cannot bypass" (`plans/github-managed-repositories-2026-09.md` Phase 2; evidence `docs/evidence/github-managed/provider-contract-2026-09-27-175844.md`).

    "clear""app_can_bypass""bypass_unknown""rulesets_unreadable""ruleset_hidden""too_many_rulesets""classic_protection_unreadable""app_in_classic_allowance""app_holds_administration_write""protection_events_not_subscribed"

  • object

    [`RulesetAuditOutcome::ProtectionEventsNotSubscribed`], with the webhook events the installation has not accepted (from the audit's detail; display only).

    kindrequired

    string

    "protection_events_not_subscribed"

    missing_eventsrequired

    array

    items

    string

  • object

    [`RulesetAuditOutcome::ClassicProtectionUnreadable`] on an installation without `administration: read`.

    kindrequired

    string

    "administration_read_missing"

  • object

    The write health refuses managed writes on its own ([`GithubWriteHealth::refuses_managed_writes`]).

    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"

    kindrequired

    string

    "writes_refused"

  • object

    [`LfsVerificationResult::PointersDetected`] over the post-attach ref set, published refs included.

    kindrequired

    string

    "lfs_pointers_detected"

  • object

    [`LfsVerificationResult::Unverified`], or no verification at all.

    kindrequired

    string

    "lfs_unverified"

  • object

    The GitHub repository is archived.

    kindrequired

    string

    "github_archived"

  • object

    GitHub advertises `refname` inside the preserved namespace.

    kindrequired

    string

    "github_advertises_preserved_prefix"

    refnamerequired

    string

  • object

    This repository already holds `refname` inside the preserved namespace.

    kindrequired

    string

    "local_ref_under_preserved_prefix"

    refnamerequired

    string

  • object

    No preserved name within [`MAX_REFNAME_BYTES`] can be formed for `refname`.

    kindrequired

    string

    "preserved_name_too_long"

    refnamerequired

    string

  • object

    A GitHub ref intent is not terminal (an `ambiguous` one included).

    kindrequired

    string

    "unsettled_ref_intent"

  • object

    Another attach run for this repository is not settled.

    kindrequired

    string

    "unsettled_attach"

GithubSyncFix

string

The stable code the GitHub panel renders as the remedy for a [`GithubSyncBlocker`]. Closed, so the SPA's copy table is exhaustive; one code may answer several blockers ([`GithubSyncBlocker::fix`] is the map).

"contact_operator""install_github_app""wait_for_binding_audit""recheck_github_app""reinstall_github_app""grant_github_app_permissions""fix_github_rulesets""accept_github_app_events""resume_sync""check_write_health""remove_lfs_pointers""recheck_lfs""unarchive_github_repository""remove_preserved_refs_on_github""rename_local_ref""resolve_ref_intent""settle_attach"