Skip to content

Commit 700158b

Browse files
ulughbeckclaude
andcommitted
feat(server,web): a GitHub owner can use its own account
Per-host account choice cannot serve personal and work repositories on the same host. Settings can now pin a gh login to a user or organization; its repositories are read and written with that login, and viewers, searches and stat batches are grouped by account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 611132c commit 700158b

19 files changed

Lines changed: 665 additions & 83 deletions

‎apps/server/src/pullRequest/GitHubPullRequestCli.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const mockApi = Layer.effect(
7777
Effect.gen(function* () {
7878
const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
7979
return GitHubApi.GitHubApi.of({
80+
accountFor: () => Effect.succeed(null),
8081
graphql: (input) =>
8182
budget
8283
.query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined)
@@ -467,6 +468,7 @@ it.effect(
467468
const credentials = Layer.succeed(
468469
GitHubCredentials.GitHubCredentials,
469470
GitHubCredentials.GitHubCredentials.of({
471+
accountFor: () => Effect.succeed(null),
470472
get: (host) =>
471473
Effect.sync(() => ({
472474
host,

‎apps/server/src/pullRequest/GitHubPullRequestCli.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,11 @@ export class GitHubPullRequestCli extends Context.Service<
482482
readonly cwd: string;
483483
readonly host: string;
484484
}) => Effect.Effect<string, GitHubPullRequestCliError>;
485+
/** The login Settings pin for this repository's owner, or null for the host's own. */
486+
readonly repositoryAccount: (input: {
487+
readonly host: string;
488+
readonly repository: string;
489+
}) => Effect.Effect<string | null>;
485490

486491
readonly listPullRequests: (input: {
487492
readonly cwd: string;
@@ -1765,6 +1770,8 @@ export const make = Effect.gen(function* () {
17651770
Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
17661771
?.credentialFingerprint ?? null,
17671772
Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
1773+
// An owner with its own account is read with its own token, never batched with others.
1774+
Context.getOrElse(context, GitHubApi.GitHubRepositoryOwner, () => null),
17681775
]),
17691776
resolver: (entries) => {
17701777
const [first] = entries;
@@ -1845,6 +1852,8 @@ export const make = Effect.gen(function* () {
18451852
Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
18461853
?.credentialFingerprint ?? null,
18471854
Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
1855+
// An owner with its own account is read with its own token, never batched with others.
1856+
Context.getOrElse(context, GitHubApi.GitHubRepositoryOwner, () => null),
18481857
]),
18491858
resolver: (entries) => {
18501859
const [first] = entries;
@@ -1893,6 +1902,8 @@ export const make = Effect.gen(function* () {
18931902
getRoutingIdentity,
18941903
getViewerLogin: (input) =>
18951904
getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)),
1905+
repositoryAccount: (input) =>
1906+
api.accountFor(input.host, parseRepositorySelector(input.repository).owner),
18961907

18971908
listPullRequests: (input) => {
18981909
const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS);

‎apps/server/src/pullRequest/GitHubPullRequestProvider.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ export const make = Effect.gen(function* () {
262262
const provider: PullRequestProviderApi = {
263263
kind: "github",
264264
capabilities: CAPABILITIES,
265+
repositoryAccount: (input) => cli.repositoryAccount(input),
265266
getRoutingIdentity: (input) =>
266267
cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))),
267268
withVerifiedCredential: (input, use) =>

‎apps/server/src/pullRequest/PullRequestProvider.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,18 @@ export interface ProviderRepositoryRef {
328328
* failing at call time.
329329
*/
330330
export interface PullRequestProviderApi {
331+
/**
332+
* The account that serves a repository when it is not the host's own, or null. GitHub can pin
333+
* an owner's repositories to their own login; the service groups viewers and batched reads by
334+
* this so one request never mixes two accounts.
335+
*/
336+
readonly repositoryAccount?: (input: {
337+
readonly host: string;
338+
readonly repository: string;
339+
}) => Effect.Effect<string | null>;
340+
/** `repository`, when given, selects the account that serves it (see `repositoryAccount`). */
331341
readonly withVerifiedCredential?: <A, E, R>(
332-
input: { readonly cwd: string; readonly host: string },
342+
input: { readonly cwd: string; readonly host: string; readonly repository?: string },
333343
use: (identity: {
334344
readonly accountId: string;
335345
readonly viewer: string;
@@ -339,17 +349,22 @@ export interface PullRequestProviderApi {
339349
readonly getRoutingIdentity?: (input: {
340350
readonly cwd: string;
341351
readonly host: string;
352+
readonly repository?: string;
342353
}) => Effect.Effect<
343354
{ readonly accountId: string; readonly viewer: string },
344355
PullRequestProviderError
345356
>;
346357
readonly kind: SourceControlProviderKind;
347358
readonly capabilities: PullRequestCapabilities;
348359

349-
/** The signed-in account, which is what involvement filtering compares against. */
360+
/**
361+
* The signed-in account, which is what involvement filtering compares against. `repository`,
362+
* when given, asks for the account that serves it rather than the host's own.
363+
*/
350364
readonly getViewer: (input: {
351365
readonly cwd: string;
352366
readonly host?: string;
367+
readonly repository?: string;
353368
}) => Effect.Effect<string, PullRequestProviderError>;
354369

355370
readonly listChangeRequests: (

‎apps/server/src/pullRequest/PullRequestService.test.ts‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
33
import * as KeyValueStore from "effect/persistence/KeyValueStore";
44
import { assert, it } from "@effect/vitest";
5+
import { GitHubRepositoryOwner } from "../sourceControl/GitHubApi.ts";
56
import * as Cause from "effect/Cause";
67
import * as Clock from "effect/Clock";
78
import * as Deferred from "effect/Deferred";
@@ -1279,6 +1280,67 @@ it.effect("tries another workspace on the same host for the viewer", () =>
12791280
}),
12801281
);
12811282

1283+
it.effect("reads an owner with its own account under that account, apart from the rest", () =>
1284+
Effect.gen(function* () {
1285+
const searches: Array<{
1286+
readonly viewer: string;
1287+
readonly owner: string | null;
1288+
readonly repositories: ReadonlyArray<string>;
1289+
}> = [];
1290+
const statReads: Array<{ readonly owner: string | null; readonly count: number }> = [];
1291+
const service = yield* makeService({
1292+
projects: [
1293+
project({ id: "p1", title: "personal", workspaceRoot: "/p1", repository: "me/one" }),
1294+
project({ id: "p2", title: "other", workspaceRoot: "/p2", repository: "me/two" }),
1295+
project({ id: "w1", title: "work", workspaceRoot: "/w1", repository: "Acme/web" }),
1296+
],
1297+
providers: [
1298+
fakeProvider("github", {
1299+
repositoryAccount: ({ repository }) =>
1300+
Effect.succeed(repository.toLowerCase().startsWith("acme/") ? "work" : null),
1301+
getViewer: () =>
1302+
Effect.map(GitHubRepositoryOwner, (owner) => (owner === null ? "personal" : "work")),
1303+
listChangeRequestsAcross: (input) =>
1304+
Effect.map(GitHubRepositoryOwner, (owner) => {
1305+
searches.push({ viewer: input.viewer, owner, repositories: input.repositories });
1306+
return {
1307+
items: input.repositories.map((repository, index) =>
1308+
batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"),
1309+
),
1310+
truncated: false,
1311+
};
1312+
}),
1313+
listChangeRequestStats: (input) =>
1314+
Effect.map(GitHubRepositoryOwner, (owner) => {
1315+
statReads.push({ owner, count: input.changeRequests.length });
1316+
return input.changeRequests.map((ref) => ({ ...ref, additions: 1, deletions: 1 }));
1317+
}),
1318+
}),
1319+
],
1320+
});
1321+
const result = yield* service.list({ state: "open", involvement: "all" });
1322+
assert.strictEqual(result.entries.length, 3);
1323+
assert.sameDeepMembers(searches, [
1324+
{ viewer: "personal", owner: null, repositories: ["me/one", "me/two"] },
1325+
{ viewer: "work", owner: "Acme", repositories: ["Acme/web"] },
1326+
]);
1327+
assert.strictEqual(result.viewers["github.com"], "personal");
1328+
assert.strictEqual(result.viewers["github.com/acme"], "work");
1329+
1330+
yield* service.listStats({
1331+
refs: result.entries.map(({ projectId, repository, number }) => ({
1332+
projectId,
1333+
repository,
1334+
number,
1335+
})),
1336+
});
1337+
assert.sameDeepMembers(statReads, [
1338+
{ owner: null, count: 2 },
1339+
{ owner: "Acme", count: 1 },
1340+
]);
1341+
}),
1342+
);
1343+
12821344
it.effect("routing verifies the current account on the requested host without caching it", () =>
12831345
Effect.gen(function* () {
12841346
let viewer = "first-account";
@@ -1295,7 +1357,11 @@ it.effect("routing verifies the current account on the requested host without ca
12951357
providers: [
12961358
fakeProvider("github", {
12971359
getRoutingIdentity: (input) => {
1298-
assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" });
1360+
assert.deepStrictEqual(input, {
1361+
cwd: "/a",
1362+
host: "github.example.test",
1363+
repository: "acme/web",
1364+
});
12991365
return Effect.succeed({
13001366
viewer,
13011367
accountId: viewer === "first-account" ? "123" : "456",

0 commit comments

Comments
 (0)