github

package
v0.43.1 Latest Latest
Warning

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

Go to latest
Published: Jul 11, 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 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 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 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) 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) 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) 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) 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) AppManifest

BuildAppManifest assembles the manifest for an iterion forge GitHub App. The 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). It deliberately does NOT request `administration` (repo deletion / settings / teams / branch-protection): that grant is dangerous AND wrong for a GitHub App installation token — per GitHub docs repo webhooks require `repository_hooks:write`, not `administration`.

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 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