Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3d09c4c
feat(github): support multiple accounts
DominicVonk Aug 10, 2026
de85a7b
fix(github): address multi-account review
DominicVonk Aug 10, 2026
c6e2f67
fix(github): preserve token lookup failures
DominicVonk Aug 10, 2026
8dd34ba
fix(github): normalize auth target ports
DominicVonk Aug 10, 2026
bafdd1a
style(server): use settings module namespace
DominicVonk Aug 10, 2026
055eb51
Merge branch 'main' into t3code/support-multiple-github-tokens
DominicVonk Aug 10, 2026
683c163
fix(web): expose stale GitHub routing reset
DominicVonk Aug 10, 2026
a831127
fix(web): keep GitHub routing cleanup visible
DominicVonk Aug 10, 2026
fb7da77
refactor(web): clarify GitHub account routing
DominicVonk Aug 10, 2026
f0dd79f
refactor(github): simplify account routing
DominicVonk Aug 10, 2026
74095d6
fix(github): preserve account routing in GraphQL reads
DominicVonk Aug 10, 2026
1e3918c
Merge branch 'main' into t3code/support-multiple-github-tokens
DominicVonk Aug 11, 2026
51273b6
Merge branch 'main' into t3code/support-multiple-github-tokens
DominicVonk Aug 11, 2026
ce6650e
Merge branch 'main' into t3code/support-multiple-github-tokens
DominicVonk Aug 11, 2026
ccc0ccc
Merge branch 'main' into t3code/support-multiple-github-tokens
DominicVonk Aug 11, 2026
d90e9fd
Merge remote-tracking branch 'origin/main' into t3code/support-multip…
DominicVonk Aug 11, 2026
f84749e
refactor(github): scope account routing by host
DominicVonk Aug 11, 2026
1219d52
Merge branch 'main' into t3code/support-multiple-github-tokens
DominicVonk Aug 11, 2026
72e6b4e
Merge branch 'main' into t3code/support-multiple-github-tokens
DominicVonk Aug 12, 2026
7620cb2
Merge branch 'main' into t3code/support-multiple-github-tokens
DominicVonk Aug 12, 2026
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
2 changes: 2 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
return {
service: {
execute,
getBatchKey: (input) =>
Effect.succeed(`active:${(input.host ?? "github.com").toLowerCase()}`),
listOpenPullRequests: (input) =>
execute({
cwd: input.cwd,
Expand Down
28 changes: 27 additions & 1 deletion apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@ layer("GitHubPullRequestCli.layer", (it) => {
});

const args = callAt(0).args;
expect(callAt(0).repositories).toEqual(["acme/web"]);
expect(args).toContain("--hostname");
expect(args).toContain("github.acme.dev");
expect(args).toContain("owner=acme");
Expand Down Expand Up @@ -1298,12 +1299,37 @@ layer("GitHubPullRequestCli.layer", (it) => {
}),
);

it.effect("uses the requested host token when looking up the viewer", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(Effect.succeed(output("octocat")));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;

const viewer = yield* cli.getViewerLogin({
cwd: "/w",
host: "github.acme.test",
repository: "acme/web",
});

assert.strictEqual(viewer, "octocat");
expect(callAt(0).args).toEqual([
"api",
"user",
"--hostname",
"github.acme.test",
"--jq",
".login",
]);
}),
);

it.effect("fails when the authenticated account has no login", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValueOnce(Effect.succeed(output(" ")));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;

const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w" }));
const error = yield* Effect.flip(
cli.getViewerLogin({ cwd: "/w", host: "github.com", repository: "acme/web" }),
);

assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError");
}),
Expand Down
120 changes: 96 additions & 24 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,16 @@ export interface GitHubPullRequestDiffSlice {
export class GitHubPullRequestCli extends Context.Service<
GitHubPullRequestCli,
{
readonly getBatchKey: (input: {
readonly cwd: string;
readonly host: string;
readonly repository: string;
}) => Effect.Effect<string, GitHubPullRequestCliError>;

readonly getViewerLogin: (input: {
readonly cwd: string;
readonly host: string;
readonly repository: string;
}) => Effect.Effect<string, GitHubPullRequestCliError>;

readonly listPullRequests: (input: {
Expand Down Expand Up @@ -658,38 +666,49 @@ export const make = Effect.gen(function* () {
const graphql = (input: {
readonly cwd: string;
readonly host: string;
readonly repository: string;
readonly query: string;
readonly variables: Readonly<Record<string, string>>;
}) =>
github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
Comment thread
cursor[bot] marked this conversation as resolved.
args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }),
})
.pipe(Effect.asVoid);

/** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */
const graphqlRead = <A>(input: {
readonly cwd: string;
readonly host: string;
readonly operation: string;
/** Variables as `-f` flags, for values this module composed itself. */
readonly variables?: ReadonlyArray<readonly [string, string]>;
/**
* Variables carrying words the reader typed. Document and variables travel over stdin
* together, because argv is visible in process listings and is echoed back inside a
* process-runner failure message.
*/
readonly privateVariables?: Readonly<Record<string, string>>;
readonly query: string;
readonly decode: (raw: string) => Result.Result<A, unknown>;
}): Effect.Effect<A, GitHubPullRequestCliError> =>
github
const graphqlRead = <A>(
input: {
readonly cwd: string;
readonly host: string;
readonly operation: string;
/** Variables as `-f` flags, for values this module composed itself. */
readonly variables?: ReadonlyArray<readonly [string, string]>;
/**
* Variables carrying words the reader typed. Document and variables travel over stdin
* together, because argv is visible in process listings and is echoed back inside a
* process-runner failure message.
*/
readonly privateVariables?: Readonly<Record<string, string>>;
readonly query: string;
readonly decode: (raw: string) => Result.Result<A, unknown>;
} & (
| { readonly repository: string; readonly repositories?: never }
| { readonly repository?: never; readonly repositories: ReadonlyArray<string> }
),
): Effect.Effect<A, GitHubPullRequestCliError> => {
const repositories = input.repositories ?? [input.repository];
return github
.execute(
input.privateVariables === undefined
? {
cwd: input.cwd,
host: input.host,
repositories,
args: [
"api",
"graphql",
Expand All @@ -702,6 +721,8 @@ export const make = Effect.gen(function* () {
}
: {
cwd: input.cwd,
host: input.host,
repositories,
args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
stdin: encodeGraphQlRequestJson({
query: input.query,
Expand All @@ -724,6 +745,7 @@ export const make = Effect.gen(function* () {
);
}),
);
};

/**
* One page of the patch, read from the files API. GitHub refuses `pr diff` outright past 300
Expand All @@ -748,6 +770,8 @@ export const make = Effect.gen(function* () {
return github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: [
"api",
"--hostname",
Expand Down Expand Up @@ -810,6 +834,8 @@ export const make = Effect.gen(function* () {
const { owner, name } = parseRepositorySelector(input.repository);
const refsResult = yield* github.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: [
"api",
"--hostname",
Expand Down Expand Up @@ -850,6 +876,8 @@ export const make = Effect.gen(function* () {
github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: [
"api",
"--hostname",
Expand Down Expand Up @@ -892,15 +920,31 @@ export const make = Effect.gen(function* () {
});

return GitHubPullRequestCli.of({
getBatchKey: (input) =>
github.getBatchKey({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
}),

getViewerLogin: (input) =>
github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe(
Effect.flatMap((result) => {
const login = result.stdout.trim();
return login.length > 0
? Effect.succeed(login)
: Effect.fail(new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }));
}),
),
github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: ["api", "user", "--hostname", input.host, "--jq", ".login"],
})
.pipe(
Effect.flatMap((result) => {
const login = result.stdout.trim();
return login.length > 0
? Effect.succeed(login)
: Effect.fail(
new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }),
);
}),
),

listPullRequests: (input) => {
const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS);
Expand All @@ -911,6 +955,8 @@ export const make = Effect.gen(function* () {
github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: [
"pr",
"list",
Expand Down Expand Up @@ -1005,6 +1051,7 @@ export const make = Effect.gen(function* () {
return graphqlRead({
cwd: input.cwd,
host: input.host,
repositories: input.repositories,
operation: "searchPullRequests",
// The reader's own words are in the query, so it travels over stdin rather than in argv.
privateVariables: { q: query },
Expand Down Expand Up @@ -1040,6 +1087,7 @@ export const make = Effect.gen(function* () {
return graphqlRead({
cwd: input.cwd,
host: input.host,
repositories: chunk.map(({ repository }) => repository),
operation: "listPullRequestStats",
query,
decode: decodePullRequestStatsJson,
Expand All @@ -1060,6 +1108,8 @@ export const make = Effect.gen(function* () {
github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: [
"pr",
"view",
Expand Down Expand Up @@ -1089,6 +1139,8 @@ export const make = Effect.gen(function* () {
github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: [
"pr",
"view",
Expand Down Expand Up @@ -1142,6 +1194,8 @@ export const make = Effect.gen(function* () {
return github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: ["pr", "diff", String(input.number), ...repositoryArgs(input), "--color", "never"],
maxOutputBytes: DIFF_MAX_OUTPUT_BYTES,
timeoutMs: DIFF_TIMEOUT_MS,
Expand Down Expand Up @@ -1180,6 +1234,7 @@ export const make = Effect.gen(function* () {
graphqlRead({
cwd: input.cwd,
host: input.host,
repository: input.repository,
operation: "listReviewThreadComments",
variables: [
["-f", `owner=${owner}`],
Expand All @@ -1203,6 +1258,7 @@ export const make = Effect.gen(function* () {
graphqlRead({
cwd: input.cwd,
host: input.host,
repository: input.repository,
operation: "listReviewThreadComments",
variables: [["-f", `threadId=${threadId}`], cursorVariable(cursor)],
query: REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY,
Expand Down Expand Up @@ -1284,6 +1340,8 @@ export const make = Effect.gen(function* () {
return github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: [
"api",
"graphql",
Expand Down Expand Up @@ -1315,6 +1373,8 @@ export const make = Effect.gen(function* () {
github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: [
"repo",
"view",
Expand Down Expand Up @@ -1344,6 +1404,7 @@ export const make = Effect.gen(function* () {
return graphqlRead({
cwd: input.cwd,
host: input.host,
repository: input.repository,
operation: "getViewerAccess",
variables: [
["-f", `owner=${owner}`],
Expand All @@ -1360,6 +1421,7 @@ export const make = Effect.gen(function* () {
return graphqlRead({
cwd: input.cwd,
host: input.host,
repository: input.repository,
operation: "listReviewerCandidates",
variables: [
["-f", `owner=${owner}`],
Expand All @@ -1376,6 +1438,8 @@ export const make = Effect.gen(function* () {
return github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
// Posting to a login GitHub has already been asked about is what a re-request is, so
// there is nothing to say here about somebody who has reviewed once already. The body
// travels over stdin for the reason every other one does: argv is visible in process
Expand All @@ -1400,6 +1464,8 @@ export const make = Effect.gen(function* () {
return github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
args: ["pr", subcommand!, String(input.number), ...repositoryArgs(input), ...flags],
})
.pipe(Effect.asVoid);
Expand All @@ -1409,6 +1475,8 @@ export const make = Effect.gen(function* () {
github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
// The body travels over stdin: argv is visible in process listings and is echoed
// back inside process-runner failure messages.
args: [
Expand All @@ -1428,6 +1496,8 @@ export const make = Effect.gen(function* () {
return github
.execute({
cwd: input.cwd,
host: input.host,
repositories: [input.repository],
// The whole review is one request, so nothing is visible to anyone else until the
// verdict is sent. The payload travels over stdin for the same reason a comment
// body does: argv is visible in process listings and echoed back in failures.
Expand All @@ -1454,6 +1524,7 @@ export const make = Effect.gen(function* () {
graphql({
cwd: input.cwd,
host: input.host,
repository: input.repository,
query: REVIEW_THREAD_REPLY_GRAPHQL_MUTATION,
variables: { threadId: input.threadId, body: input.body },
}),
Expand All @@ -1462,6 +1533,7 @@ export const make = Effect.gen(function* () {
graphql({
cwd: input.cwd,
host: input.host,
repository: input.repository,
query: input.resolved
? RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION
: UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION,
Expand Down
Loading
Loading