Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/repo-fork.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'gcb-core': minor
'gcb-client': minor
'gcb-host': minor
---

Add `gcb repo fork [owner/name]`: create a fork under a person's account with `--as-user` (broker
authorized user or host opener). The app identity cannot own a fork, so it is refused.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ Full detail and the measurements behind them:
|---|---|
| `packages/core/src/` | shared parser, help/version, protocol, types, platform, paths and the gh action catalog |
| `packages/client/src/helper.ts` | the container-side credential helper |
| `packages/client/src/commands/` | setup, probe, logs, pr, issue, gh, remotes and the shared host-submit helper |
| `packages/client/src/commands/` | setup, probe, logs, pr, issue, repo, gh, remotes and the shared host-submit helper |
| `packages/host/src/broker.ts` | unix socket server, refusal codes, audit records |
| `packages/host/src/config.ts` | YAML configuration |
| `packages/host/src/gh.ts` | the isolated gh action executor |
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ mints is narrowed to `pull_requests: write` and `contents: read`, while `resolve
`contents: write` — a grant the broker gives only to the credential git pushes with, never to a
pull-request token.

`gcb repo fork [owner/name]` creates a fork under a person's account. An app cannot own a fork, so
`--as-user` is required: bare uses the broker's authorized user, a login uses the host opener. Without
a repository it forks the checkout's `origin`. `--host` selects the host; otherwise the origin remote's host is
used when available, and `github.com` is the fallback.

Inside a checkout, `--repo` can be omitted only when `origin` names a usable GitHub repository whose
host matches `--host`; otherwise pass `--repo`, or the matching `--host`.

Expand Down Expand Up @@ -385,6 +390,7 @@ host, beside the same checkout, and ask for it with `--as-user <login>` from the
```sh
# on the host, once — it serves every checkout below $HOME and does nothing else
gcb host-opener # --root <dir> to narrow it, repeatable
# for repo fork, also pass --config <broker.config.yml> so its allowlist applies
# or, at boot and for good: examples/host-opener-boot.sh (see below)
```

Expand All @@ -397,7 +403,8 @@ gcb pr --as-user octocat --repo owner/repo --head feature --base main \
--title "a title" --body-file pr.md
```

The opener creates pull requests and issues with the gh account named by the request. `--as-user <login>`
The opener creates pull requests and issues with the gh account named by the request, and forks only
a repository present in the broker config's allowlist for the request host (pass `--config`). `--as-user <login>`
selects `~/.config/gh-<login>` on the host, matching the login exactly including case; create that
directory once with `GH_CONFIG_DIR="$HOME/.config/gh-octocat" gh auth login`. The directory must
already exist, and inherited `GH_TOKEN`/`GITHUB_TOKEN`/`GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN`/`GH_HOST` are ignored
Expand Down
1 change: 1 addition & 0 deletions examples/host-opener.service
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ After=network-online.target
# http_proxy / https_proxy / no_proxy in this file if this host needs a proxy to reach github.com.
EnvironmentFile=-/etc/git-cred-broker/proxy.env
Type=simple
# For `repo fork`, add --config <broker.config.yml> so the opener checks its allowlist.
ExecStart=/usr/bin/env gcb host-opener --root %h
Restart=always
RestartSec=10
Expand Down
7 changes: 4 additions & 3 deletions packages/client/src/commands/host-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function submitToOpener(
request: HostRequest,
success: (answer: HostOpenResult) => string,
): Promise<number> {
const noun = request.kind === 'issue' ? 'issue' : 'pull request';
const noun = request.kind === 'fork' ? 'fork' : request.kind === 'issue' ? 'issue' : 'pull request';
const gitDir = findGitDir(process.cwd());
if (gitDir === undefined) fail('--as-user needs a git repository: run it from the checkout');
const dir = channelDir(gitDir);
Expand All @@ -68,8 +68,9 @@ export async function submitToOpener(
' or drop --as-user to open it as the app instead',
);
}
if (request.kind === 'issue' && !(Array.isArray(opener.kinds) && opener.kinds.includes('issue'))) {
fail('the host opener does not advertise issue support; restart it from this gcb-host version');
const needed = request.kind === 'issue' ? 'issue' : request.kind === 'fork' ? 'fork' : undefined;
if (needed !== undefined && !(Array.isArray(opener.kinds) && opener.kinds.includes(needed))) {
fail(`the host opener does not advertise ${needed} support; restart it from this gcb-host version`);
}

// writeRequest refuses what the opener would refuse — a title past its limit, a body past its limit,
Expand Down
120 changes: 120 additions & 0 deletions packages/client/src/commands/repo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* `repo` — repo-level actions that need a person.
*
* `repo fork` is the one operation an app cannot perform for itself: a fork must be owned by a user
* account, and an installation token has none. The command therefore requires `--as-user`; bare
* selects the broker's authorized person, a login selects the host opener.
*/
import { LOGIN, REPOSITORY, fail, parseArgs, say } from 'gcb-core';

import { requestOverSocket, resolveSocketPath } from '../helper.ts';
import { remoteRepo } from './remotes.ts';
import { submitToOpener } from './host-submit.ts';

const KNOWN_FLAGS = new Set([
'help', 'h', 'as-user', 'proxy', 'no-proxy', 'socket', 'host',
]);

export const REPO_USAGE = `Usage: gcb repo fork [owner/name] [options]

Create a fork under a person's account. The app identity cannot own a fork, so --as-user is
required. Without an owner/name the source comes from the checkout's origin remote.

--as-user Use the broker's authorized user token
--as-user <login> Use the host opener with ~/.config/gh-<login>
--host <host> Host as configured (default: the origin remote's host, else github.com)
--socket <path> Broker socket (default: GIT_BROKER_SOCKET)
--proxy <url> Use this proxy for this request
--no-proxy Reach GitHub directly for this request
-h, --help Show this help
`;

export async function runRepo(argv: readonly string[]): Promise<number> {
const args = parseArgs(argv, { booleans: ['help', 'no-proxy'] });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (args.has('help') || args.has('h')) {
say(REPO_USAGE);
return 0;
}
if (args.positionals[0] !== 'fork') fail('usage: gcb repo fork [owner/name]');
if (args.positionals.length > 2) fail('unexpected argument ' + JSON.stringify(args.positionals[2]));
for (const name of args.names) {
if (!KNOWN_FLAGS.has(name)) fail('unknown option --' + name + ' for gcb repo fork');
}

const accounts = args.values('as-user');
const asUser = args.has('as-user');
if (accounts.length > 1) fail('--as-user may only be given once');
if (asUser && accounts.length > 0) fail('--as-user cannot be both bare and given a login');
// `--as-user acme/widget` is an unambiguous source repository, not an account: a login cannot
// contain a slash. It saves making the positional depend on whether a bare flag was used.
let account = accounts[0];
let sourceFromAsUser: string | undefined;
if (account !== undefined && REPOSITORY.test(account)) {
sourceFromAsUser = account;
account = undefined;
}
if (account !== undefined && !LOGIN.test(account)) {
fail(`--as-user must be a GitHub login, a repository, or no value, got ${JSON.stringify(account)}`);
}
if (account === undefined && sourceFromAsUser === undefined && !asUser) {
fail('--as-user is required for fork: the app identity cannot own a fork');
}

const proxyValues = args.values('proxy');
if (args.has('proxy') && proxyValues.length === 0) fail('--proxy needs a value');
if (args.has('no-proxy') && (args.has('proxy') || proxyValues.length > 0)) {
fail('--proxy and --no-proxy are mutually exclusive');
}
const proxy = args.has('no-proxy') ? null : args.value('proxy');

const origin = remoteRepo('origin', process.cwd());
const source =
sourceFromAsUser ?? args.positionals[1] ?? (origin === undefined ? undefined : `${origin.owner}/${origin.repo}`);
if (source === undefined) fail('pass owner/name, or run this from a checkout with an origin remote');
if (!REPOSITORY.test(source)) fail(`not a repository: ${JSON.stringify(source)}`);
const hostValue = args.value('host');
if ((args.has('host') || args.values('host').length > 0) && (hostValue === undefined || hostValue.trim() === '')) {
fail('--host needs a value');
}
const host = hostValue ?? origin?.host ?? 'github.com';

if (account !== undefined) {
return submitToOpener(
{ kind: 'fork', repo: source, host, account, proxy, session: 'repo', pid: process.pid },
(answer) => `forked ${source} as ${account}: ${answer.url ?? '(no url)'}`,
);
}

const socketPath = resolveSocketPath(args.value('socket'), process.env);
if (socketPath === undefined) {
fail('no broker socket: pass --socket, set GIT_BROKER_SOCKET, or run `gcb setup`');
}
let response;
try {
response = await requestOverSocket(
socketPath,
{
v: 1,
op: 'pull-request',
host,
path: source,
action: 'fork',
identity: 'user',
session: 'repo',
pid: process.pid,
proxy,
},
60_000,
);
} catch (error) {
process.stderr.write(`unreachable: ${(error as Error).message}\n`);
return 3;
}
if (!response.ok) {
process.stderr.write(`refused [${response.code ?? 'unknown'}]: ${response.reason ?? 'no reason given'}\n`);
return 1;
}
if (response.prUrl === undefined) fail('the broker did not answer with a fork URL');
say(`forked ${source}: ${response.prUrl}`);
return 0;
}
3 changes: 3 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Commands
${cataloguedSubcommands('pr')}
run Run a workflow run action: ${cataloguedSubcommands('run')}
issue Run an issue action: ${cataloguedSubcommands('issue')}
repo Create a fork as a person: fork
gh Run a catalogued GitHub action through the broker

As a git credential helper
Expand Down Expand Up @@ -77,6 +78,8 @@ async function run(argv: readonly string[]): Promise<number> {
return (await import('./commands/gh.ts')).runGh(['run', ...rest]);
case 'issue':
return (await import('./commands/issue.ts')).runIssue(rest);
case 'repo':
return (await import('./commands/repo.ts')).runRepo(rest);
case 'gh':
return (await import('./commands/gh.ts')).runGh(rest);
default:
Expand Down
58 changes: 50 additions & 8 deletions packages/core/src/host-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
// ends came to disagree about what a branch may look like.
export const BRANCH = /^([A-Za-z0-9][A-Za-z0-9._-]{0,38}:)?[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;

/** `owner/name`, as a fork request names its source. */
export const REPOSITORY = /^(?!\.+(?:\/|$))[A-Za-z0-9_.-]+\/(?!\.+$)[A-Za-z0-9_.-]+$/;

/** A host key, optionally with a port; never a URL, slash or newline. */
export const HOST = /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::\d{1,5})?$/;

interface HostRequestFields {
/** `owner/name` as the caller believed it. Recorded, and deliberately never used: the checkout a
* request was written in decides which repository it means. */
Expand Down Expand Up @@ -80,8 +86,19 @@ export interface HostIssueRequest extends HostRequestFields {
readonly kind: 'issue';
}

/** One request to fork a repository. */
export interface HostForkRequest {
readonly kind: 'fork';
readonly repo: string;
readonly host?: string;
readonly proxy?: string | null;
readonly account?: string;
readonly session?: string;
readonly pid?: number;
}

/** Either request the opener can fulfil. */
export type HostRequest = HostOpenRequest | HostIssueRequest;
export type HostRequest = HostOpenRequest | HostIssueRequest | HostForkRequest;

/** What the opener answered. */
export interface HostOpenResult {
Expand Down Expand Up @@ -276,6 +293,14 @@ export function newId(now: Date = new Date()): string {
*/
export function requestProblems(request: HostRequest): string[] {
const problems: string[] = [];
if (request.kind === 'fork') {
if (!REPOSITORY.test(request.repo)) problems.push('the repository is not owner/name');
if (request.host !== undefined && !HOST.test(request.host)) problems.push('the host is not a usable host');
if (request.account !== undefined && !LOGIN.test(request.account)) {
problems.push('the account is not a usable GitHub login');
}
return problems;
}
if (request.kind !== 'issue') {
if (!BRANCH.test(request.head) || request.head.includes('..')) {
problems.push('the head branch name is not usable');
Expand Down Expand Up @@ -323,9 +348,22 @@ export function writeRequest(dir: string, request: HostRequest): string {
*/
export function readRequest(dir: string, id: string): HostRequest | undefined {
const parsed = readJson(join(dir, `${id}.json`)) as
| (Partial<HostOpenRequest> & Partial<HostIssueRequest>)
| (Partial<HostOpenRequest> & Partial<HostIssueRequest> & Partial<HostForkRequest>)
| undefined;
if (!parsed || typeof parsed.title !== 'string') return undefined;
if (!parsed) return undefined;
if (parsed.kind === 'fork') {
if (typeof parsed.repo !== 'string' || !REPOSITORY.test(parsed.repo)) return undefined;
return {
kind: 'fork',
repo: parsed.repo,
...(typeof parsed.host === 'string' ? { host: parsed.host } : {}),
...(typeof parsed.proxy === 'string' || parsed.proxy === null ? { proxy: parsed.proxy } : {}),
...(typeof parsed.account === 'string' ? { account: parsed.account } : {}),
session: parsed.session,
pid: parsed.pid,
};
}
if (typeof parsed.title !== 'string') return undefined;
const common = {
repo: typeof parsed.repo === 'string' ? parsed.repo : undefined,
title: parsed.title,
Expand Down Expand Up @@ -414,13 +452,17 @@ export function claimRequest(dir: string, id: string): HostRequest | undefined {
// this next, and a request missing a string field would make them index undefined and take it down.
const shaped =
request !== undefined &&
typeof request.title === 'string' &&
typeof request.body === 'string' &&
// Untyped JSON: the file can spell account as an array or a number even though the type cannot.
(request.account === undefined || typeof request.account === 'string') &&
(request.kind === 'issue'
? true
: typeof request.head === 'string' && typeof request.base === 'string');
(request.kind === 'fork'
? typeof request.repo === 'string' &&
REPOSITORY.test(request.repo) &&
(request.host === undefined || (typeof request.host === 'string' && HOST.test(request.host)))
: typeof request.title === 'string' &&
typeof request.body === 'string' &&
(request.kind === 'issue'
? true
: typeof request.head === 'string' && typeof request.base === 'string'));
if (!shaped) {
rmSync(claimed, { force: true, recursive: true });
return undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { platformRefusal } from './platform.ts';
import { PACKAGE_VERSION } from './version.ts';

/** Commands implemented by the container-side package. */
export const CLIENT_COMMANDS = ['setup', 'probe', 'logs', 'pr', 'run', 'issue', 'gh'] as const;
export const CLIENT_COMMANDS = ['setup', 'probe', 'logs', 'pr', 'run', 'issue', 'repo', 'gh'] as const;

/** Commands implemented by the host-side package. */
export const HOST_COMMANDS = [
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ export type PullRequestAction =
| 'threads'
| 'reply'
| 'edit'
| 'resolve';
| 'resolve'
| 'fork';

/** What a provider is asked to do to a pull request. */
export interface PullRequestRequest {
Expand Down
19 changes: 14 additions & 5 deletions packages/host/src/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,8 @@ export function createRequestHandler(
action !== 'threads' &&
action !== 'reply' &&
action !== 'edit' &&
action !== 'resolve'
action !== 'resolve' &&
action !== 'fork'
) {
return deny(context, CODES.BAD_REQUEST, `bad action ${JSON.stringify(action)}`, 'unknown pull request action');
}
Expand All @@ -405,6 +406,9 @@ export function createRequestHandler(
if (identity !== undefined && identity !== 'app' && identity !== 'user') {
return deny(context, CODES.BAD_REQUEST, `bad identity ${JSON.stringify(identity)}`, 'the identity must be app or user');
}
if (action === 'fork' && identity !== 'user') {
return deny(context, CODES.BAD_REQUEST, 'fork without a user identity', 'a fork must be owned by a person');
}
const title = message['title'];
const body = message['body'];
const filePath = message['filePath'];
Expand Down Expand Up @@ -466,7 +470,13 @@ export function createRequestHandler(
if (action === 'resolve' && (typeof threadId !== 'string' || threadId === '')) {
return deny(context, CODES.BAD_REQUEST, 'bad threadId', 'resolving needs the id of the thread, which --threads prints');
}
if (action !== 'open' && action !== 'resolve' && action !== 'edit' && (typeof number !== 'number' || !Number.isInteger(number) || number <= 0)) {
if (
action !== 'open' &&
action !== 'resolve' &&
action !== 'edit' &&
action !== 'fork' &&
(typeof number !== 'number' || !Number.isInteger(number) || number <= 0)
) {
return deny(context, CODES.BAD_REQUEST, `bad number ${JSON.stringify(number)}`, 'this action needs a pull request number');
}
if (typeof provider.pullRequest !== 'function') {
Expand Down Expand Up @@ -509,12 +519,11 @@ export function createRequestHandler(
...(typeof commentId === 'number' ? { comment_id: commentId } : {}),
...(typeof head === 'string' ? { head } : {}),
...(typeof base === 'string' ? { base } : {}),
pr_number: result.number,
pr_url: result.url,
...(action === 'fork' ? { fork_url: result.url } : { pr_number: result.number, pr_url: result.url }),
});
return {
ok: true,
prNumber: result.number,
...(action === 'fork' ? {} : { prNumber: result.number }),
prUrl: result.url,
prState: result.state,
prStatus: result.status,
Expand Down
Loading
Loading