github

package
v0.2.8 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

Documentation

Overview

Package github holds the host-side GitHub integration: the credential store, the device-flow runtime, the GitHub API client, and the orchestration that combines them for "create a PR from a worktree" requests.

Credentials live in ~/.local/share/clank/github.json — sibling to the Anthropic sink in internal/host/auth.go. The credential never travels through clank's infrastructure: the device flow runs between this process and github.com directly, and PR creation calls the GitHub API from this process too. The gateway is a pure proxy for the connect-flow status and PR creation requests.

Index

Constants

View Source
const (
	// CredentialSourceStore is a token from clank's own device-flow
	// store — connected/disconnected through clank.
	CredentialSourceStore = "store"
	// CredentialSourceGhCLI is a token borrowed live from the
	// machine's gh CLI login. clank stores nothing and cannot
	// disconnect it — that's `gh auth logout`'s job — so clients
	// should hide the disconnect affordance for this source.
	CredentialSourceGhCLI = "gh_cli"
)

Status is the wire shape returned from GET /credentials/github/status. CredentialSource values for Status.Source.

View Source
const ClientIDEnv = "CLANK_GITHUB_OAUTH_CLIENT_ID"

ClientIDEnv is the environment variable carrying the Clank GitHub OAuth App's client_id. When unset, the Manager reports available:false and refuses to start the device flow. The env-var approach lets sprite provisioners set it without code changes; the laptop's clankd forwards it from its own environment.

View Source
const GitCredentialGhCLIAuthFlag = "--gh-cli-auth"

GitCredentialGhCLIAuthFlag lets the helper borrow the machine's gh login.

Variables

View Source
var (
	// ErrNotConfigured is returned by StartConnect when the host has
	// no CLANK_GITHUB_OAUTH_CLIENT_ID. The mux maps it to 503.
	ErrNotConfigured = errors.New("github: client_id not configured (set CLANK_GITHUB_OAUTH_CLIENT_ID)")

	// ErrUnknownFlow is returned when a status/cancel call targets a
	// flow the manager has no record of (typo, expired TTL, or a
	// fresh start has bumped the slot).
	ErrUnknownFlow = errors.New("github: unknown flow id")
)

Errors returned by the device-flow surface. Map to specific HTTP statuses in the mux handlers — see github_connect.go.

View Source
var (
	// ErrPRAlreadyExists is GitHub's 422 "A pull request already
	// exists for X:Y" error. ExistingURL is the URL of the open PR
	// we found via the follow-up search.
	ErrPRAlreadyExists = errors.New("github pr: already exists for this head")

	// ErrPRBaseNotFound covers 422 errors where the base branch
	// doesn't exist on the remote. Distinct from "already exists" so
	// the UI can show the right hint.
	ErrPRBaseNotFound = errors.New("github pr: base branch not found")

	// ErrPRTokenInvalid is GitHub's 401 — typically means the token
	// was revoked or expired. UI should prompt the user to reconnect.
	ErrPRTokenInvalid = errors.New("github pr: token invalid or revoked")

	// ErrPRForbidden is GitHub's 403 — token can't write to this
	// repo (e.g. lacks `repo` scope, or repo doesn't accept PRs from
	// this account).
	ErrPRForbidden = errors.New("github pr: forbidden")
)

Errors returned by CreatePullRequest. ErrPRAlreadyExists carries the existing PR URL so the mux handler can surface it in the response body — the UI uses it to deep-link instead of showing a dead-end "conflict" error.

View Source
var (
	ErrRepositoryNotFound     = errors.New("github: repository not found")
	ErrRepositoryForbidden    = errors.New("github: repository forbidden")
	ErrRepositoryTokenInvalid = errors.New("github: repository token invalid")
)
View Source
var ErrNotConnected = errors.New("github: not connected")

ErrNotConnected reports that no GitHub credential is stored on this host — the user hasn't completed the device flow. Distinct from ErrNotConfigured (the host has no OAuth client_id at all).

View Source
var ErrNotGitHubRemote = errors.New("not a github.com remote")

ErrNotGitHubRemote is returned by ParseGitHubRemote when the URL doesn't point to github.com. v1 supports github.com only; GHE and non-GitHub forges are deferred.

View Source
var ErrPullRequestNotFound = errors.New("github: pull request not found")

ErrPullRequestNotFound reports that GitHub did not expose the requested PR.

View Source
var ErrRepoNameTaken = errors.New("github: a repository with that name already exists on this account")

ErrRepoNameTaken is GitHub's 422 when the authenticated user already owns a repository with the requested name.

Functions

func ExistingURLFromError

func ExistingURLFromError(err error) string

ExistingURLFromError extracts the existing-PR URL from an error returned by CreatePullRequest. Returns "" when the error isn't the "already exists" case or carries no URL.

func GitCredentialHelperValue

func GitCredentialHelperValue(executable string, canUseGhCLIAuth bool) string

GitCredentialHelperValue routes a repo's auth prompts to this binary. The executable is quoted so paths with spaces survive the helper shell.

func ParseGitHubRemote

func ParseGitHubRemote(remoteURL string) (owner, repo string, err error)

ParseGitHubRemote extracts owner and repo from a github.com remote URL. Accepts the three forms git uses in practice:

https://github.com/owner/repo(.git)?
git@github.com:owner/repo(.git)?
ssh://git@github.com/owner/repo(.git)?

Anything else returns ErrNotGitHubRemote, including non-github.com hosts and URLs that don't have both an owner and a repo segment.

func RemoteHost

func RemoteHost(remoteURL string) string

RemoteHost returns just the host portion of a git remote URL, regardless of whether the host is github.com. Used by the PR preview endpoint to surface the actual host in "non_github" error messages (e.g. "Origin points to gitlab.com").

Returns the empty string when the URL is unparseable. Accepts the same three URL shapes as ParseGitHubRemote.

func RunGitCredentialHelper

func RunGitCredentialHelper(action string, in io.Reader, out io.Writer, store *Store, canUseGhCLIAuth bool) error

RunGitCredentialHelper implements the helper protocol over in/out for one invocation. Only `get` for protocol=https host=github.com is answered, and only when an allowed credential source has a token. Every other case prints nothing so git treats it as "no credential". Store read errors are returned rather than hidden as disconnection.

Types

type Branch

type Branch struct {
	Name      string `json:"name"`
	Protected bool   `json:"protected"`
}

Branch is the trimmed branch shape returned to clients for the import branch picker. Protected mirrors GitHub's branch-protection flag so the UI can badge protected branches; the picker marks the repo's default using the default_branch already carried by the repos list.

type CheckRollup

type CheckRollup struct {
	State   CheckRollupState `json:"state"`
	Passed  int              `json:"passed"`
	Failed  int              `json:"failed"`
	Pending int              `json:"pending"`
	Total   int              `json:"total"`
}

CheckRollup aggregates a commit's check runs: the overall State plus the per-bucket counts (Passed + Failed + Pending == Total).

type CheckRollupState

type CheckRollupState string

CheckRollupState is the one-glance CI verdict for a commit.

const (
	CheckStatePassing CheckRollupState = "passing"
	CheckStateFailing CheckRollupState = "failing"
	CheckStatePending CheckRollupState = "pending"
)

type CreatePRInput

type CreatePRInput struct {
	Title string `json:"title"`
	Body  string `json:"body"`
	Head  string `json:"head"`
	Base  string `json:"base"`
	Draft bool   `json:"draft,omitempty"`
}

CreatePRInput is the request body for CreatePullRequest. Head is just the branch name — same-owner PRs only in v1; cross-fork support would require "owner:branch" syntax.

type CreateRepoInput

type CreateRepoInput struct {
	Name        string
	Description string
	Private     bool
}

CreateRepoInput is the request for CreateRepository. Creating a PRIVATE repo needs the token's `repo` scope — the connect flow already requests it.

type CreatedRepo

type CreatedRepo struct {
	Owner    string `json:"owner"`
	Name     string `json:"name"`
	FullName string `json:"full_name"`
	Private  bool   `json:"private"`
	HTMLURL  string `json:"html_url"`
	CloneURL string `json:"clone_url"`
}

CreatedRepo is the trimmed shape returned after a successful create.

type Credentials

type Credentials struct {
	AccessToken  string    `json:"access_token"`
	RefreshToken string    `json:"refresh_token,omitempty"`
	ExpiresAt    time.Time `json:"expires_at,omitzero"`
	Scopes       []string  `json:"scopes,omitempty"`
	GitHubLogin  string    `json:"github_login,omitempty"`
	GitHubUserID int64     `json:"github_user_id,omitempty"`
	InstalledAt  time.Time `json:"installed_at,omitzero"`
}

Credentials is the on-disk shape at Store.Path(). Only AccessToken is required; the other fields are best-effort metadata captured when the device flow completes so the UI can show "@login" without a round-trip to GitHub.

type DeviceFlowStart

type DeviceFlowStart struct {
	FlowID                  string    `json:"flow_id"`
	UserCode                string    `json:"user_code"`
	VerificationURI         string    `json:"verification_uri"`
	VerificationURIComplete string    `json:"verification_uri_complete"`
	ExpiresAt               time.Time `json:"expires_at"`
	Interval                int       `json:"interval"`
}

DeviceFlowStart is the response from StartConnect. UserCode and VerificationURIComplete are what the client (mobile/TUI) shows to the user — opening VerificationURIComplete in a browser brings up the GitHub page with UserCode pre-filled.

type DeviceFlowState

type DeviceFlowState string

DeviceFlowState is the state a flow is in. Mirrors the agent.DeviceFlow alphabet but lives in this package so the github surface stays decoupled from the broader provider catalog.

const (
	FlowPending  DeviceFlowState = "pending"
	FlowSuccess  DeviceFlowState = "success"
	FlowDenied   DeviceFlowState = "denied"
	FlowExpired  DeviceFlowState = "expired"
	FlowError    DeviceFlowState = "error"
	FlowCanceled DeviceFlowState = "canceled"
)

type DeviceFlowStatus

type DeviceFlowStatus struct {
	State       DeviceFlowState `json:"state"`
	Error       string          `json:"error,omitempty"`
	GitHubLogin string          `json:"github_login,omitempty"`
}

DeviceFlowStatus is the polled state of an in-progress flow. GitHubLogin is populated only when State is "success" — same atomic step that wrote the credential file fetched the login.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager owns this host's GitHub integration: the credential store, the in-progress device flow (if any), and the HTTP client used for outbound calls to github.com. Single Manager per host.Service.

Device-flow plumbing lives in device_flow.go; PR creation lives in pr.go (PR 3). Base URLs are overridable via SetAuthBaseURL / SetAPIBaseURL so tests can swap an httptest.Server.

func NewManager

func NewManager(homeDir, clientID string) *Manager

NewManager constructs a Manager rooted at homeDir with the given OAuth App client_id. Empty clientID is allowed — the manager will report available:false and refuse to start the device flow, which is the correct behavior for hosts that haven't been configured with a GitHub integration (e.g. self-hosters who didn't set the env var, or laptop dev runs without one).

func (*Manager) AccessToken

func (m *Manager) AccessToken() (string, error)

AccessToken returns the GitHub access token every API and authed-git flow should use, or ErrNotConnected when none is available. Centralizes the "connected?" check so callers don't re-implement it. An explicit clank connection (the store) always wins; with the gh CLI fallback enabled, a laptop host borrows the machine's own gh login instead of demanding a second device flow.

func (*Manager) CanUseGhCLIAuth

func (m *Manager) CanUseGhCLIAuth() bool

CanUseGhCLIAuth reports whether this host may borrow the machine's gh login.

func (*Manager) CancelConnect

func (m *Manager) CancelConnect(_ context.Context, flowID string) error

CancelConnect signals the polling goroutine to stop and transitions the flow to "canceled". Idempotent — calling cancel on a flow that's already terminal is a no-op.

func (*Manager) CheckRollupForRef

func (m *Manager) CheckRollupForRef(ctx context.Context, token, owner, repo, ref string) (*CheckRollup, error)

CheckRollupForRef aggregates the latest check runs on ref (a SHA or branch). Returns nil when the commit has no check runs at all — "no CI configured" renders as nothing, not as green.

func (*Manager) ClientID

func (m *Manager) ClientID() string

ClientID returns the configured OAuth App client_id; empty when the manager is unavailable.

func (*Manager) ConnectStatus

func (m *Manager) ConnectStatus(_ context.Context, flowID string) (DeviceFlowStatus, error)

ConnectStatus returns the current state of flowID. Pure read. ErrUnknownFlow when the ID doesn't match the active slot (either it never existed, or a newer StartConnect replaced it).

func (*Manager) CreatePullRequest

func (m *Manager) CreatePullRequest(ctx context.Context, accessToken, owner, repo string, in CreatePRInput) (PullRequest, error)

CreatePullRequest opens a PR on the named repo with the given token. On 422 "already exists", looks up the existing PR via a follow-up GET and returns ErrPRAlreadyExists with the URL embedded.

func (*Manager) CreateRepository

func (m *Manager) CreateRepository(ctx context.Context, token string, in CreateRepoInput) (CreatedRepo, error)

CreateRepository creates a repository owned by the authenticated user (the empty org argument means "the token's user"). AutoInit is false on purpose: we push the worktree's existing history, so an initial commit on GitHub's side would make the first push a non-fast-forward.

func (*Manager) Disconnect

func (m *Manager) Disconnect(_ context.Context) error

Disconnect removes the stored credential. Idempotent: missing file is not an error.

func (*Manager) EnableGhCLIFallback

func (m *Manager) EnableGhCLIFallback()

EnableGhCLIFallback lets AccessToken (and Status) fall back to the machine's own gh CLI login when no clank connection exists. A deployment decision, not a default: the local laptop provisioner enables it — the host IS the user's machine, where agent sessions could run `gh auth token` themselves, so borrowing it exposes nothing new — while sandboxes keep token access explicit. Call once at wiring time, before the manager serves requests.

func (*Manager) FindOpenPRForBranch

func (m *Manager) FindOpenPRForBranch(ctx context.Context, accessToken, owner, repo, branch string) (*PullRequest, error)

FindOpenPRForBranch returns the open PR whose head is owner:branch, or nil when none exists. (nil, nil) means "no open PR"; a non-nil error is a real API/transport failure the caller can treat as best-effort.

func (*Manager) GetPullRequest

func (m *Manager) GetPullRequest(ctx context.Context, token, owner, repo string, number int) (PullRequestDetails, error)

GetPullRequest returns one PR. An empty token deliberately makes an anonymous request, which is sufficient for public repositories.

func (*Manager) GetRepository

func (m *Manager) GetRepository(ctx context.Context, token, owner, repo string) (RepositoryDetails, error)

GetRepository returns one repository. An empty token deliberately makes an anonymous request so public repositories do not require a GitHub connection.

func (*Manager) HTTPClient

func (m *Manager) HTTPClient() *http.Client

HTTPClient returns the configured client. PRs 2/3 use this for outbound GitHub calls so tests can swap it via SetHTTPClient.

func (*Manager) IsAvailable

func (m *Manager) IsAvailable() bool

IsAvailable reports whether GitHub Connect is enabled on this host. Returns false when ClientIDEnv was unset at startup.

func (*Manager) ListBranches

func (m *Manager) ListBranches(ctx context.Context, token, owner, repo string) ([]Branch, error)

ListBranches lists the branches of owner/repo as returned by GitHub, capped at maxBranches. token authenticates the call (private repos need it). Mirrors ListRepositories' paginate-and-trim approach.

func (*Manager) ListPullRequests

func (m *Manager) ListPullRequests(ctx context.Context, token, owner, repo string, state PRListState) ([]PullRequestSummary, error)

ListPullRequests lists owner/repo's pull requests in the given state, most recently updated first, capped at maxPulls. token authenticates the call (private repos need it). Mirrors ListBranches' paginate-and-trim approach.

func (*Manager) ListRepositories

func (m *Manager) ListRepositories(ctx context.Context, token string) ([]Repo, error)

ListRepositories lists repositories the authenticated user can access (owner, collaborator, and org-member affiliations), most recently pushed first, capped at maxRepos. token authenticates the call.

func (*Manager) ListTemplateRepositories

func (m *Manager) ListTemplateRepositories(ctx context.Context, token string) ([]TemplateRepo, error)

ListTemplateRepositories lists repositories OWNED by the authenticated user that are marked as templates, most recently pushed first, capped at maxRepos. Owner-only affiliation is the v1 product boundary: "your own templates" — org/community sources come later with their own trust story. Membership in this listing is also the create-time authorization: the gateway only creates from ids it can find here (or in the operator catalog).

func (*Manager) MarkPRReadyForReview

func (m *Manager) MarkPRReadyForReview(ctx context.Context, accessToken, owner, repo string, number int) error

MarkPRReadyForReview flips PR `number` from draft to ready-for-review. Idempotent: an already-ready PR is a successful no-op (no mutation is sent).

func (*Manager) PRMergeable

func (m *Manager) PRMergeable(ctx context.Context, token, owner, repo string, number int) (MergeableState, error)

PRMergeable reports whether PR number merges cleanly into its base.

func (*Manager) SetAPIBaseURL

func (m *Manager) SetAPIBaseURL(u string)

SetAPIBaseURL overrides the api.github.com base URL for user info and PR creation.

func (*Manager) SetAuthBaseURL

func (m *Manager) SetAuthBaseURL(u string)

SetAuthBaseURL overrides the github.com base URL used for the device-flow and token-exchange calls. Tests set this to httptest.Server.URL; production leaves it at the default.

func (*Manager) SetHTTPClient

func (m *Manager) SetHTTPClient(c *http.Client)

SetHTTPClient overrides the client used for outbound GitHub calls. Tests use this to point at an httptest.Server simulating github.com.

func (*Manager) SetPollSafetyMargin

func (m *Manager) SetPollSafetyMargin(d time.Duration)

SetPollSafetyMargin overrides the slack added to each device-flow poll interval. Defaults to 3s (matches RFC 8628 §3.4 guidance); tests set 0 so the flow polls at GitHub's returned cadence.

func (*Manager) StartConnect

func (m *Manager) StartConnect(ctx context.Context) (DeviceFlowStart, error)

StartConnect kicks off the device flow against GitHub and spawns a goroutine that polls until completion. Returns the user-facing fields the client renders. Any in-flight flow is canceled first — single-slot registry per host.

func (*Manager) Status

func (m *Manager) Status(_ context.Context) (Status, error)

Status returns the current connection state. Reads the credential file on every call — cheap and avoids stale-cache bugs across disconnect/reconnect. With the gh CLI fallback enabled, a host with no stored token but a logged-in gh still reports connected (source gh_cli, login unknown — identity isn't part of gh's token handoff).

func (*Manager) Store

func (m *Manager) Store() *Store

Store returns the credential store. Exposed for PRs 2/3 that need to write/read credentials from the device-flow goroutine and the PR creation path.

func (*Manager) StoredLogin

func (m *Manager) StoredLogin() string

StoredLogin returns the connected account's login when clank knows it, "" otherwise (no connection, or a gh CLI-borrowed token — identity isn't part of gh's token handoff). Best-effort: callers use it for annotations like is_mine, never for auth decisions.

type MergeableState

type MergeableState string

MergeableState is whether a PR can merge cleanly into its base.

const (
	MergeableStateMergeable   MergeableState = "mergeable"
	MergeableStateConflicting MergeableState = "conflicting"
	// MergeableStateUnknown means GitHub hasn't computed the test merge
	// yet. The GET itself kicks the computation off, so a later refresh
	// resolves it — callers should treat unknown as "not yet", not retry
	// inline.
	MergeableStateUnknown MergeableState = "unknown"
)

type PRListState

type PRListState string

PRListState selects which pull requests ListPullRequests returns.

const (
	PRListStateOpen   PRListState = "open"
	PRListStateClosed PRListState = "closed"
)

type PullRequest

type PullRequest struct {
	Number  int    `json:"number"`
	HTMLURL string `json:"html_url"`
	Draft   bool   `json:"draft"`
	Head    struct {
		SHA string `json:"sha"`
	} `json:"head"`
	Base struct {
		Ref string `json:"ref"`
	} `json:"base"`
}

PullRequest mirrors the subset of GitHub's response we surface upstream — the broader go-github type leaks through the package boundary otherwise.

type PullRequestDetails

type PullRequestDetails struct {
	Number     int    `json:"number"`
	Title      string `json:"title"`
	HTMLURL    string `json:"html_url"`
	HeadOwner  string `json:"head_owner"`
	HeadRepo   string `json:"head_repo"`
	HeadBranch string `json:"head_branch"`
	HeadSHA    string `json:"head_sha"`
	BaseBranch string `json:"base_branch"`
	Author     string `json:"author"`
	IsPrivate  bool   `json:"is_private"`
}

PullRequestDetails identifies the exact code revision behind a GitHub PR.

type PullRequestSummary

type PullRequestSummary struct {
	Number     int       `json:"number"`
	Title      string    `json:"title"`
	State      string    `json:"state"`
	Draft      bool      `json:"draft"`
	HTMLURL    string    `json:"html_url"`
	HeadBranch string    `json:"head_branch"`
	HeadSHA    string    `json:"head_sha"`
	BaseBranch string    `json:"base_branch"`
	Author     string    `json:"author"`
	UpdatedAt  time.Time `json:"updated_at,omitzero"`
	MergedAt   time.Time `json:"merged_at,omitzero"`
}

PullRequestSummary is the trimmed PR shape returned to clients. HeadBranch is what the UI cross-references against loaded worktrees (to link a PR to a branch you already have) or forks from (to check a PR out). MergedAt is non-zero only for merged PRs — GitHub reports merged PRs as state "closed", so this is the merged-vs-abandoned discriminator.

type Repo

type Repo struct {
	Owner         string    `json:"owner"`
	Name          string    `json:"name"`
	FullName      string    `json:"full_name"`
	Private       bool      `json:"private"`
	DefaultBranch string    `json:"default_branch"`
	Description   string    `json:"description,omitempty"`
	UpdatedAt     time.Time `json:"updated_at,omitzero"`
}

Repo is the trimmed repository shape returned to clients. FullName is "owner/name"; Owner/Name are split out so the import endpoint can take them as discrete fields without re-parsing.

type RepositoryDetails

type RepositoryDetails struct {
	Owner         string `json:"owner"`
	Name          string `json:"name"`
	HTMLURL       string `json:"html_url"`
	Description   string `json:"description"`
	DefaultBranch string `json:"default_branch"`
	IsPrivate     bool   `json:"is_private"`
}

RepositoryDetails is the repository metadata needed before Clank imports code.

type Status

type Status struct {
	Available   bool      `json:"available"`
	Connected   bool      `json:"connected"`
	Source      string    `json:"source,omitempty"` // CredentialSource* when connected
	GitHubLogin string    `json:"github_login,omitempty"`
	Scopes      []string  `json:"scopes,omitempty"`
	InstalledAt time.Time `json:"installed_at,omitzero"`
}

`available` is the host-level capability flag; `connected` is the per-user "do we have a token" flag. Both are needed by the UI so it can distinguish "this host doesn't support GitHub" from "this host supports it but you haven't connected yet."

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store reads and writes Credentials atomically. Safe for concurrent use; in practice only one writer ever exists per host (the device flow's polling goroutine).

func NewStore

func NewStore(homeDir string) *Store

NewStore constructs a Store rooted at homeDir. The credential file lives at <homeDir>/.local/share/clank/github.json.

func (*Store) Delete

func (s *Store) Delete() error

Delete removes the credential file. Missing file is not an error — disconnect is idempotent so the UI doesn't have to think about "I already disconnected" races.

func (*Store) IsConnected

func (s *Store) IsConnected() bool

IsConnected reports whether a usable access token is stored. Convenience wrapper around Read for callers that don't care about the rest of the credential.

func (*Store) Path

func (s *Store) Path() string

Path returns the absolute path of the credential file.

func (*Store) Read

func (s *Store) Read() (Credentials, error)

Read returns the persisted credentials. A missing file is treated as "not connected" — returns a zero Credentials with nil error so callers can use IsConnected to branch without an error check.

func (*Store) Write

func (s *Store) Write(c Credentials) error

Write replaces the credential atomically: write a sibling .tmp, then rename. A reader observing the file at any instant sees either the previous credential or the new one — never partial JSON.

type TemplateRepo

type TemplateRepo struct {
	Repo
	CloneURL string `json:"clone_url"`
}

TemplateRepo is one entry in the template listing: the trimmed repo shape plus the clone URL the gateway needs for create-time resolution. The clone URL never travels past the gateway.

Jump to

Keyboard shortcuts

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