github

package
v1.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package github is the GitHub implementation of forge.Admin: the outbound write-side client the orchestrator uses to list repos and create/update/delete the iterion webhook on them. Shared by the OAuth-App path (user token) and, later, the GitHub-App path (installation token) — they differ only in how the token is obtained, not in these REST calls.

Index

Constants

This section is empty.

Variables

View Source
var DefaultScopes = []string{"repo"}

DefaultScopes is the OAuth-App scope set a GitHub connection requests. `repo` covers the whole job iterion's OAuth-App path needs: read the diff, post PR comments, and manage repo webhooks (it subsumes admin:repo_hook).

Least-privilege: `read:org` is deliberately NOT requested. ListRepos already surfaces org repositories via `/user/repos?affiliation=…, organization_member`, which `repo` alone authorizes; `read:org` only adds org/team-membership *enumeration* (the user's full org graph) that iterion does not use here. Dropping it keeps the connection token off the user's org-membership data. Operators wanting curated org-repo access should use the least-privilege GitHub-App connect flow (operator-selected repos).

View Source
var ErrInstallationNotOwned = fmt.Errorf("github: installation not accessible to the authorizing user")

ErrInstallationNotOwned is returned by VerifyInstallationOwnership when the authenticated user provably does NOT have access to the installation — the signal the install callback maps to a 403 (an attacker substituting another org's installation_id).

Functions

func APIBaseFor

func APIBaseFor(webBase string) string

APIBaseFor maps a GitHub WEB base URL to its REST API base. github.com → api.github.com; a GitHub Enterprise host → <host>/api/v3.

func AppManageURL

func AppManageURL(webBase, ownerLogin, ownerType, slug string) string

AppManageURL is the GitHub settings page (Advanced tab, with the Delete button) for a created App, so the operator can remove it on the forge — GitHub exposes no API to delete an App. Empty when the slug is unknown.

func DeliveryInstallationPermissions added in v1.0.0

func DeliveryInstallationPermissions() map[string]string

DeliveryInstallationPermissions are what a bot needs to SHIP an application rather than only change code: publish the CI definition that builds it, and publish the resulting image.

They are separated from the runtime baseline because `workflows: write` is a genuine escalation — an actor who can rewrite CI can run arbitrary code in it — so an App only gets them when the operator opts in at creation.

GitHub enforces this hard, not softly: pushing a commit that touches .github/workflows/** with a token lacking `workflows` is REJECTED outright ("refusing to allow a GitHub App to create or update workflow … without `workflows` permission"), which blocks the whole build-and-deploy chain at its first step.

func LastMintedPermissions added in v1.0.0

func LastMintedPermissions(installationID int64) (map[string]string, bool)

LastMintedPermissions returns the permissions carried by the most recent runtime token minted for an installation in this process, and whether one is known.

func MintInstallationToken

func MintInstallationToken(ctx context.Context, httpClient *http.Client, apiBase string, cfg AppConfig, installationID int64, now time.Time, opts *InstallationTokenOptions) (string, time.Time, error)

MintInstallationToken trades the App JWT for a short-lived (≈1h) installation access token. apiBase is the REST API base (APIBaseFor). opts may be nil for an unconstrained (whole-installation) token, or narrow it to specific repositories + a permission subset (least-privilege).

func MissingDeliveryPermissions added in v1.0.0

func MissingDeliveryPermissions(granted map[string]string) []string

MissingDeliveryPermissions lists the delivery grants an installation does NOT have, so the connection health view can name them BEFORE a run spends hours discovering them at push time. Empty when nothing is missing (or when the grant set is unknown — absence of data is not evidence of a gap).

func RecordRuntimePermissions added in v1.0.0

func RecordRuntimePermissions(installationID int64, perms map[string]string)

RecordRuntimePermissions notes what a runtime token carried. Called by the mint paths that produce the token a RUN uses.

func RepoAdminInstallationPermissions added in v0.50.0

func RepoAdminInstallationPermissions() map[string]string

RepoAdminInstallationPermissions is the permission subset minted ONLY for a CreateRepo call. administration:write is a broad grant (repo settings), so it never rides the cached runtime token (RuntimeInstallationPermissions) — each create mints its own short-lived token and drops it.

func RuntimeInstallationPermissions

func RuntimeInstallationPermissions() map[string]string

RuntimeInstallationPermissions is the least-privilege permission subset an installation token minted for iterion is pinned to — exactly what the forge layer needs (read/push code, open+comment PRs, comment the source issue for the MR back-link, manage the per-repo webhook, the mandatory metadata baseline) and nothing more. Mirrors the App manifest (BuildAppManifest); pinning it at mint time means a token stays minimal even if the installation is later granted broader permissions on the forge.

func RuntimePermissionsFor added in v1.0.0

func RuntimePermissionsFor(granted map[string]string) map[string]string

RuntimePermissionsFor narrows the permissions iterion WANTS to those the installation actually granted.

Minting is not forgiving: asking for a permission the installation lacks fails the whole call (422), so a token cannot simply request the superset. Intersecting keeps one code path for both an App created before delivery permissions existed and one created with them.

granted == nil means "unknown" (a connection stored before grants were recorded); those keep exactly the historical baseline.

func VerifyInstallationOwnership

func VerifyInstallationOwnership(ctx context.Context, httpClient *http.Client, webBase string, cfg AppConfig, code string, installationID int64) error

VerifyInstallationOwnership proves the user completing a GitHub-App install callback actually has access to the installation they claim, closing the IDOR where installation_id (an enumerable integer taken verbatim from the callback URL) is trusted without a check. It:

  1. exchanges the user-authorization `code` for a user-to-server token (the App must have "Request user authorization (OAuth) during installation" enabled, so GitHub appends `code` to the setup redirect);
  2. lists the installations that token can see (GET /user/installations);
  3. returns nil only when installationID is among them.

A user cannot mint a code for an installation they don't control, and /user/installations only returns installations the user can access, so a forged installation_id fails at step 3. Returns ErrInstallationNotOwned when the installation is absent, or a wrapped error on exchange/API failure (callers fail closed on any error).

Types

type AdminClient

type AdminClient struct {
	HTTP    *http.Client
	APIBase string // e.g. "https://api.github.com" or "https://ghe.example.com/api/v3"
	Token   string
}

AdminClient talks to one GitHub instance (github.com or GHE) as one connection. Auth is a Bearer token (an OAuth user token, a PAT, or a GitHub-App installation token — GitHub accepts all three the same way).

func New

func New(httpClient *http.Client, baseURL, token string) *AdminClient

New builds an AdminClient. baseURL is the forge's WEB base ("https://github.com" or a GHE host); it is mapped to the matching REST API base. A nil httpClient falls back to http.DefaultClient.

func (*AdminClient) CollaboratorPermission

func (c *AdminClient) CollaboratorPermission(ctx context.Context, repo, user string) (string, error)

CollaboratorPermission returns user's permission on repo ("owner/repo") via GET /repos/{repo}/collaborators/{user}/permission — one of admin|maintain|write|triage|read|none (role_name when present, else the legacy permission). A 404 (not a collaborator) is "none", not an error. Used by the inbound-webhook command gate to authorize a commenter against a bot's MinReplierRole.

func (*AdminClient) CommentIssue

func (c *AdminClient) CommentIssue(ctx context.Context, repo string, number int, body string) (forge.CommentRef, error)

CommentIssue posts a comment on an issue (or PR — GitHub shares the endpoint).

func (*AdminClient) CreateHook

func (c *AdminClient) CreateHook(ctx context.Context, repo string, spec forge.HookSpec) (forge.HookHandle, error)

func (*AdminClient) CreateIssue

func (c *AdminClient) CreateIssue(ctx context.Context, repo string, in forge.NewIssue) (forge.IssueRef, error)

CreateIssue opens a new issue (board→forge push).

func (*AdminClient) CreatePull

func (c *AdminClient) CreatePull(ctx context.Context, repo string, in forge.NewPull) (forge.PullRef, error)

CreatePull opens a pull request. head/base are branch names; Draft maps to GitHub's `draft` create flag.

func (*AdminClient) CreateRepo added in v0.50.0

func (c *AdminClient) CreateRepo(ctx context.Context, spec forge.RepoCreateSpec) (forge.RepoSummary, error)

CreateRepo implements forge.RepoCreator. Owner "" targets the token's own user namespace (POST /user/repos); an org login targets POST /orgs/{owner}/repos. Spec.DefaultBranch is intentionally not sent: on an empty GitHub repo the FIRST PUSH decides the default branch (Appy's `git init -b main` flow), and with InitReadme GitHub applies the owner's configured default.

func (*AdminClient) DeleteHook

func (c *AdminClient) DeleteHook(ctx context.Context, repo, hookID string) error

func (*AdminClient) GetCIStatus

func (c *AdminClient) GetCIStatus(ctx context.Context, repo, ref string) (forge.CIStatus, error)

GetCIStatus returns the CURRENT aggregate CI state + runs for a ref, combining GitHub Actions/App check-runs with the legacy commit-status API.

func (*AdminClient) GetFile added in v0.50.0

func (c *AdminClient) GetFile(ctx context.Context, repo, path, ref string) (forge.FileRef, error)

GetFile reads a single file via GET /repos/{repo}/contents/{path}. A missing path returns forge.ErrFileNotFound; a directory (or symlink/submodule) is an error, not a silent empty read.

func (*AdminClient) GetHook

func (c *AdminClient) GetHook(ctx context.Context, repo, deliveryURL string) (*forge.HookHandle, error)

func (*AdminClient) GetIssue

func (c *AdminClient) GetIssue(ctx context.Context, repo string, number int) (forge.IssueRef, error)

GetIssue fetches one issue (or PR) by number.

func (*AdminClient) GetPullRequest

func (c *AdminClient) GetPullRequest(ctx context.Context, repo string, number int) (forge.PullRef, error)

GetPullRequest fetches one PR by number.

func (*AdminClient) ListCIHistory

func (c *AdminClient) ListCIHistory(ctx context.Context, repo, ref string, limit int) ([]forge.CIRun, error)

ListCIHistory lists recent check-runs for a ref/branch, newest first (by start time), capped at limit (0 → 30).

func (*AdminClient) ListHooks

func (c *AdminClient) ListHooks(ctx context.Context, repo string) ([]forge.HookHandle, error)

ListHooks returns every webhook registered on repo (not just the iterion-owned one), for operator audit. Mirrors GetHook's listing but without the delivery-URL filter.

func (*AdminClient) ListIssues

func (c *AdminClient) ListIssues(ctx context.Context, repo string, opts forge.IssueListOptions) ([]forge.IssueRef, error)

ListIssues lists issues for repo ("owner/repo"). PRs are returned by the same endpoint but flagged via IsPullRequest so callers can drop them.

func (*AdminClient) ListPullRequests

func (c *AdminClient) ListPullRequests(ctx context.Context, repo string, opts forge.PullListOptions) ([]forge.PullRef, error)

ListPullRequests lists PRs for repo ("owner/repo"). GitHub's /pulls endpoint has no `since` filter, so opts.Since is ignored here.

func (*AdminClient) ListRepos

func (c *AdminClient) ListRepos(ctx context.Context, q forge.RepoQuery) ([]forge.RepoSummary, error)

ListRepos returns repos the token can admin (Permissions.Admin) — the floor for managing repo webhooks.

func (*AdminClient) MergePull

func (c *AdminClient) MergePull(ctx context.Context, repo string, number int, opts forge.MergeOptions) (forge.PullRef, error)

MergePull merges a PR via PUT /pulls/{n}/merge, then re-fetches it once so the returned ref reflects the merged state. When opts.DeleteBranch is set, the source branch (read off the re-fetched ref) is best-effort deleted afterwards — a failure there does not fail the merge.

func (*AdminClient) OrgMembershipRole

func (c *AdminClient) OrgMembershipRole(ctx context.Context, org string) (role string, active bool, err error)

OrgMembershipRole reports the caller's role ("admin" | "member") in org and whether the membership is active, via GET /user/memberships/orgs/{org}. A 404/403 (not a member, or no visibility) returns ("", false, nil) — the caller treats that as "no proof of control", not an error. Used to verify an iterion team controls (admins) a GitHub org before its teams may be allow-listed for SSO.

func (*AdminClient) Provider

func (c *AdminClient) Provider() forge.Provider

func (*AdminClient) PutFile added in v0.50.0

func (c *AdminClient) PutFile(ctx context.Context, repo string, in forge.PutFile) (forge.FileRef, error)

PutFile writes a single file via PUT /repos/{repo}/contents/{path}. Updating an existing file requires in.PrevSHA; a stale value yields HTTP 409, mapped to forge.ErrFileConflict so the caller re-reads instead of clobbering.

func (*AdminClient) UpdateHook

func (c *AdminClient) UpdateHook(ctx context.Context, repo, hookID string, spec forge.HookSpec) (forge.HookHandle, error)

func (*AdminClient) UpdateIssue

func (c *AdminClient) UpdateIssue(ctx context.Context, repo string, number int, patch forge.IssuePatch) (forge.IssueRef, error)

UpdateIssue applies a partial update; nil patch fields are left untouched.

func (*AdminClient) UpdatePull

func (c *AdminClient) UpdatePull(ctx context.Context, repo string, number int, patch forge.PullPatch) (forge.PullRef, error)

UpdatePull applies a partial update. GitHub's REST PATCH covers title/body/ base/state; converting draft↔ready is GraphQL-only, so PullPatch carries no draft toggle.

func (*AdminClient) WhoAmI

func (c *AdminClient) WhoAmI(ctx context.Context) (forge.Identity, error)

type AppClient

type AppClient struct {
	HTTP           *http.Client
	WebBaseURL     string
	Cfg            AppConfig
	InstallationID int64
	Now            func() time.Time
	// contains filtered or unexported fields
}

AppClient is a forge.Admin for one GitHub-App installation. It mints + caches the installation token (refreshing ≈60s before expiry) and delegates the actual REST calls to an AdminClient. Repo listing + identity differ from a user token (an installation token can't read /user), so those are overridden.

func (*AppClient) CreateHook

func (a *AppClient) CreateHook(ctx context.Context, repo string, spec forge.HookSpec) (forge.HookHandle, error)

func (*AppClient) CreateRepo added in v0.50.0

func (a *AppClient) CreateRepo(ctx context.Context, spec forge.RepoCreateSpec) (forge.RepoSummary, error)

CreateRepo (AppClient) mints a dedicated administration:write installation token for this single call — the cached runtime token keeps its minimal permission set. Requires the installation to have the Administration permission granted; an ungranted install surfaces forge.ErrPermissionsNotGranted with GitHub's actionable message (fix: approve the App's pending permission update on GitHub).

func (*AppClient) DeleteHook

func (a *AppClient) DeleteHook(ctx context.Context, repo, hookID string) error

func (*AppClient) GetHook

func (a *AppClient) GetHook(ctx context.Context, repo, deliveryURL string) (*forge.HookHandle, error)

func (*AppClient) ListHooks

func (a *AppClient) ListHooks(ctx context.Context, repo string) ([]forge.HookHandle, error)

func (*AppClient) ListRepos

func (a *AppClient) ListRepos(ctx context.Context, q forge.RepoQuery) ([]forge.RepoSummary, error)

ListRepos lists the installation's repositories (GET /installation/repositories) — an installation token's repo set, not the user's. The App was installed with webhook-write permission, so every listed repo is admin-capable (a missing permission surfaces as a 403 on CreateHook, mapped to insufficient_scope).

func (*AppClient) Provider

func (a *AppClient) Provider() forge.Provider

func (*AppClient) UpdateHook

func (a *AppClient) UpdateHook(ctx context.Context, repo, hookID string, spec forge.HookSpec) (forge.HookHandle, error)

func (*AppClient) WhoAmI

func (a *AppClient) WhoAmI(context.Context) (forge.Identity, error)

WhoAmI returns the App identity — an installation token can't call /user, and the bot posts AS the App, so this is the correct "post as" handle.

type AppConfig

type AppConfig struct {
	AppID         int64
	PrivateKeyPEM string
	AppSlug       string // for the install URL github.com/apps/<slug>/installations/new
	// ClientID/ClientSecret are the App's user-authorization OAuth
	// credentials. Optional: when both are set (and the App has "Request
	// user authorization (OAuth) during installation" enabled on GitHub),
	// the install callback verifies the completing user actually owns the
	// installation before minting a token for it — see
	// VerifyInstallationOwnership. Empty → verification is unavailable.
	ClientID     string
	ClientSecret string
}

AppConfig is the global GitHub-App identity (registered once on GitHub), shared across every installation. The private key never leaves the process; it is loaded from deployment config, not from Mongo.

func (AppConfig) Configured

func (c AppConfig) Configured() bool

func (AppConfig) UserAuthConfigured

func (c AppConfig) UserAuthConfigured() bool

UserAuthConfigured reports whether the App carries the OAuth client credentials needed to verify installation ownership at connect time.

type AppManifest

type AppManifest struct {
	Name        string `json:"name"`
	URL         string `json:"url"`
	RedirectURL string `json:"redirect_url"`
	// CallbackURLs are the user-authorization (OAuth) callback URLs baked into
	// the created App. WITHOUT this the subsequent "connect via OAuth" step fails
	// with GitHub's "This GitHub App must be configured with a callback URL" —
	// RedirectURL above only covers the one-shot manifest-conversion redirect,
	// not the recurring user-to-server OAuth the connect flow uses.
	CallbackURLs []string `json:"callback_urls"`
	// SetupURL is where GitHub redirects AFTER the user installs the App on
	// repos — without it GitHub stays put and iterion never sees the
	// installation, so the github_app connection is never created. It points at
	// the install callback, which the install flow's state flows through to.
	SetupURL           string            `json:"setup_url"`
	SetupOnUpdate      bool              `json:"setup_on_update"`
	Public             bool              `json:"public"`
	DefaultEvents      []string          `json:"default_events"`
	DefaultPermissions map[string]string `json:"default_permissions"`
	HookAttributes     map[string]any    `json:"hook_attributes"`
}

AppManifest is the GitHub App manifest iterion POSTs to <web>/settings/apps/new — the only programmatic path to create a GitHub app (there is no create-OAuth-app REST endpoint). The created App's client_id/client_secret then drive the existing OAuth user-to-server connect flow (OAuthApp).

func BuildAppManifest

func BuildAppManifest(name, homeURL, redirectURL string, opts ...AppManifestOptions) AppManifest

BuildAppManifest assembles the manifest for an iterion forge GitHub App. The baseline permissions are the LEAST-PRIVILEGE set iterion's forge layer actually needs: push/read code (contents), open + comment on PRs (pull_requests), the mandatory metadata baseline, and manage the per-repo inbound webhook (repository_hooks — the App-level webhook is disabled, iterion creates per-repo hooks itself). `administration` (repo settings / deletion / branch-protection) is never part of the baseline — webhooks require `repository_hooks:write`, not `administration` — and only joins the grant when opts.AllowRepoCreation asks for the create-repo capability.

type AppManifestOptions added in v0.50.0

type AppManifestOptions struct {
	// AllowRepoCreation adds administration:write to the App's requested
	// permissions so iterion can CREATE repositories in the installed
	// org (the "new app → new repo" launch journey). Broad grant, hence
	// opt-in at App-creation time and surfaced in the connect UI; at run
	// time it is minted per CreateRepo call only
	// (RepoAdminInstallationPermissions) — the cached runtime token stays
	// on RuntimeInstallationPermissions.
	AllowRepoCreation bool
	// AllowAppDelivery adds workflows:write + packages:write so a bot can
	// publish the CI that builds an app and the image it produces — the
	// second half of the "new app → new repo → deployed" journey. Opt-in
	// because `workflows: write` lets the holder rewrite CI, i.e. run
	// arbitrary code in it.
	AllowAppDelivery bool
}

AppManifestOptions tunes the App's requested grant set beyond the least-privilege runtime baseline.

type AppRefresher

type AppRefresher struct {
	HTTP *http.Client
	Cfg  AppConfig
	Now  func() time.Time
	// Repos, when set, returns the short repo names (e.g. "api", not
	// "org/api") this connection actually operates on, so the runtime
	// forge_token is scoped to that repo set (least-privilege) instead of the
	// whole installation. A nil slice with a nil error → whole-installation
	// (still minimal permissions). A non-nil error means the set could not be
	// determined; Refresh fails closed rather than minting a broader token.
	// Injected by the server so the refresher stays free of a store dependency.
	Repos func(ctx context.Context, conn forge.Connection) ([]string, error)
}

AppRefresher re-mints the installation token for the connection's managed forge_token secret (forge.TokenRefresher). The refreshToken arg is unused — a GitHub App re-mints from its private key, not a refresh token.

func (AppRefresher) Refresh

type Installation added in v1.0.0

type Installation struct {
	Login   string
	HTMLURL string
	// Permissions is the grant the ORG actually approved, which can be
	// narrower than what the App requests (a permission added to an App after
	// installation stays pending until an owner approves it). Carrying it lets
	// the mint ask only for what exists, and lets the health probe name a
	// missing grant BEFORE a run spends hours discovering it at push time.
	Permissions map[string]string
}

Installation is what GET /app/installations/{id} tells us about a live installation.

func InstallationInfo added in v0.50.0

func InstallationInfo(ctx context.Context, httpClient *http.Client, apiBase string, cfg AppConfig, installationID int64, now time.Time) (info Installation, err error)

InstallationInfo returns the installation's account login, its GitHub settings page URL (html_url — the only place where the installation's repo scope and permission grants can be widened), and its approved permissions. App-JWT-authenticated: GET /app/installations/{id}.

type InstallationTokenOptions

type InstallationTokenOptions struct {
	Repositories []string
	Permissions  map[string]string
}

InstallationTokenOptions narrows a minted installation token below the installation's full grant (least-privilege). Both fields are optional; a nil field means "don't constrain that dimension" (GitHub returns the installation's full set).

  • Repositories: short repo names (e.g. "api", NOT "org/api") the token may touch. Empty → all repositories in the installation.
  • Permissions: the permission subset (a subset of the installation's own grants). Empty → the installation's full permission set.

type ManifestConversion

type ManifestConversion struct {
	ID           int64  `json:"id"`
	Slug         string `json:"slug"`
	ClientID     string `json:"client_id"`
	ClientSecret string `json:"client_secret"`
	// PEM is the App's private key — the credential the least-privilege
	// github_app (installation-token) path needs. WebhookSecret is the App's
	// generated webhook secret. Both are returned once by the conversion and
	// must be captured here (GitHub won't re-issue the private key).
	PEM           string `json:"pem"`
	WebhookSecret string `json:"webhook_secret"`
	Owner         struct {
		Login string `json:"login"`
		Type  string `json:"type"` // "Organization" | "User"
	} `json:"owner"`
}

ManifestConversion is the subset of GitHub's app-manifest conversion response iterion keeps: the App id + slug + owner (to deep-link its settings) and the OAuth client credentials.

func ConvertManifest

func ConvertManifest(ctx context.Context, httpClient *http.Client, webBase, code string) (ManifestConversion, error)

ConvertManifest exchanges the temporary code GitHub returns after the operator confirms the manifest for the created App's credentials, via POST {apiBase}/app-manifests/{code}/conversions. The code is single-use and expires in ~1h; no auth header is needed (the code is the credential).

type OAuthApp

type OAuthApp struct {
	HTTP         *http.Client
	BaseURL      string // WEB base ("https://github.com" or a GHE host)
	ClientID     string
	ClientSecret string
}

OAuthApp drives the GitHub OAuth-App authorization-code flow for one GitHub instance (github.com or GHE). Classic OAuth Apps issue non-expiring tokens and don't support PKCE, so there is no refresh path (the connection carries no expiry → the refresh worker skips it).

func (*OAuthApp) AuthorizeURL

func (a *OAuthApp) AuthorizeURL(redirectURI, state, _ string, scopes []string) string

AuthorizeURL builds the redirect URL. codeChallenge is ignored — classic GitHub OAuth Apps don't support PKCE.

func (*OAuthApp) Exchange

func (a *OAuthApp) Exchange(ctx context.Context, code, redirectURI, _ string) (forge.RefreshedToken, error)

Exchange trades the authorization code for an access token.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL