Skip to content
Open
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
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope,
[WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope,
[WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope,
Expand All @@ -66,6 +67,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope,
[WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope,
[WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope,
[WS_METHODS.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope,
// Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
// client pressing refresh must not be told it may not look again.
[WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
Expand Down
144 changes: 144 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2603,4 +2603,148 @@ layer("GitHubPullRequestCli.layer", (it) => {
]);
}),
);

it.effect("reads every page of viewed files, and says so when there are too many", () =>
Effect.gen(function* () {
const page = (index: number, hasNextPage: boolean) =>
Effect.succeed(
output(
JSON.stringify({
data: {
repository: {
pullRequest: {
files: {
pageInfo: { hasNextPage, endCursor: `cursor-${index}` },
nodes: [
{ path: `src/file${index}.ts`, viewerViewedState: "VIEWED" },
{ path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" },
],
},
},
},
},
}),
),
);
mockedExecute
.mockReturnValueOnce(page(0, true))
.mockReturnValueOnce(page(1, true))
.mockReturnValueOnce(page(2, false));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;

const viewed = yield* cli.getPullRequestFilesViewed({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
});

assert.strictEqual(mockedExecute.mock.calls.length, 3);
// The first page asks from the start; each one after it carries the cursor before it.
assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after=")));
expect(callAt(1).args).toContain("after=cursor-0");
expect(callAt(2).args).toContain("after=cursor-1");
assert.isFalse(viewed.truncated);
expect(viewed.files.map((file) => [file.path, file.state])).toEqual([
["src/file0.ts", "viewed"],
["src/other0.ts", "unviewed"],
["src/file1.ts", "viewed"],
["src/other1.ts", "unviewed"],
["src/file2.ts", "viewed"],
["src/other2.ts", "unviewed"],
]);
}),
);

it.effect("stops paging viewed files rather than following a change without end", () =>
Effect.gen(function* () {
mockedExecute.mockReturnValue(
Effect.succeed(
output(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({
data: {
repository: {
pullRequest: {
files: {
pageInfo: { hasNextPage: true, endCursor: "cursor" },
nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }],
},
},
},
},
}),
),
),
);
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;

const viewed = yield* cli.getPullRequestFilesViewed({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
});

assert.strictEqual(mockedExecute.mock.calls.length, 5);
assert.isTrue(viewed.truncated);
assert.strictEqual(viewed.files.length, 5);
}),
);

it.effect("clears and restores a burst of files in one request", () =>
Effect.gen(function* () {
mockedExecute
.mockReturnValueOnce(
Effect.succeed(
// @effect-diagnostics-next-line preferSchemaOverJson:off
output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })),
),
)
.mockReturnValueOnce(Effect.succeed(output("{}")));
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;

yield* cli.setPullRequestFilesViewed({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
files: [
{ path: "src/a.ts", viewed: true },
{ path: "src/b.ts", viewed: false },
],
});

// One request to learn the pull request's node id, one for every press together.
assert.strictEqual(mockedExecute.mock.calls.length, 2);
// @effect-diagnostics-next-line preferSchemaOverJson:off
const sent = JSON.parse(callAt(1).stdin ?? "") as {
query: string;
variables: Record<string, string>;
};
expect(sent.query).toContain("f0: markFileAsViewed");
expect(sent.query).toContain("f1: unmarkFileAsViewed");
expect(sent.variables).toEqual({
pullRequestId: "PR_1",
path0: "src/a.ts",
path1: "src/b.ts",
});
}),
);

it.effect("asks the host nothing when nothing was pressed", () =>
Effect.gen(function* () {
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;

yield* cli.setPullRequestFilesViewed({
cwd: "/w",
repository: "acme/web",
host: "github.com",
number: 7,
files: [],
});

assert.strictEqual(mockedExecute.mock.calls.length, 0);
}),
);
});
92 changes: 92 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resolvePullRequestAuthorFilter,
type PullRequestAction,
type PullRequestActor,
type PullRequestFileViewed,
type PullRequestInvolvement,
type PullRequestListFilters,
type PullRequestListState,
Expand All @@ -30,10 +31,12 @@ import {
ADD_REACTION_GRAPHQL_MUTATION,
buildReviewSubmissionJson,
buildReviewerRequestJson,
buildSetFilesViewedGraphQlMutation,
decodeActorAvatarsJson,
decodePullRequestActivityJson,
decodePullRequestDetailJson,
decodePullRequestFilesJson,
decodePullRequestFilesViewedJson,
decodePullRequestListJson,
decodePullRequestNodeIdJson,
decodePullRequestSearchJson,
Expand All @@ -53,6 +56,7 @@ import {
decodeBaseComparisonJson,
PULL_REQUEST_DETAIL_JSON_FIELDS,
PULL_REQUEST_LIST_JSON_FIELDS,
PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
REMOVE_REACTION_GRAPHQL_MUTATION,
Expand Down Expand Up @@ -263,6 +267,12 @@ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000;

/** What the files API serves at most in one response, which is what one slice is made of. */
const DIFF_FILES_PAGE_SIZE = 100;
/**
* How many hundred-file pages of viewed state one read will walk. A point of the hourly GraphQL
* budget per page, against a change request nobody reviews in one sitting past the first few
* hundred files: beyond this the read stops and says it was cut short.
*/
const FILES_VIEWED_MAX_PAGES = 5;

/**
* Pages of review threads to follow before the conversation is reported as truncated. GitHub
Expand Down Expand Up @@ -308,6 +318,12 @@ export interface GitHubPullRequestDiffSlice {
readonly omittedFileStats?: ReadonlyArray<PullRequestOmittedFileStat>;
}

export interface GitHubPullRequestFilesViewed {
readonly files: ReadonlyArray<PullRequestFileViewed>;
/** GitHub had more files than the page budget below would read. */
readonly truncated: boolean;
}

export class GitHubPullRequestCli extends Context.Service<
GitHubPullRequestCli,
{
Expand Down Expand Up @@ -415,6 +431,30 @@ export class GitHubPullRequestCli extends Context.Service<
GitHubPullRequestCliError
>;

/**
* Which files of the pull request the signed-in account has cleared, and which of those have
* been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL,
* and because the two answers go stale at completely different rates.
*/
readonly getPullRequestFilesViewed: (input: {
readonly cwd: string;
readonly repository: string;
readonly host: string;
readonly number: number;
}) => Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestCliError>;

/**
* Clears files, or puts them back, as one request. GitHub takes a single path per mutation,
* so a burst is batched with aliases into one document rather than one subprocess per press.
*/
readonly setPullRequestFilesViewed: (input: {
readonly cwd: string;
readonly repository: string;
readonly host: string;
readonly number: number;
readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
}) => Effect.Effect<void, GitHubPullRequestCliError>;

readonly listReviewThreadComments: (input: {
readonly cwd: string;
readonly repository: string;
Expand Down Expand Up @@ -1763,6 +1803,58 @@ export const make = Effect.gen(function* () {
variables: { threadId: input.threadId, body: input.body },
}),

getPullRequestFilesViewed: (input) => {
const { owner, name } = parseRepositorySelector(input.repository);
const read = (
after: string | null,
collected: ReadonlyArray<PullRequestFileViewed>,
pagesLeft: number,
): Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestCliError> =>
graphqlRead({
cwd: input.cwd,
host: input.host,
operation: "getPullRequestFilesViewed",
variables: [
["-f", `owner=${owner}`],
["-f", `name=${name}`],
["-F", `number=${input.number}`],
...(after === null
? []
: ([["-f", `after=${after}`]] as ReadonlyArray<readonly [string, string]>)),
],
query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
decode: decodePullRequestFilesViewedJson,
}).pipe(
Effect.flatMap((page) => {
const files = [...collected, ...page.files];
if (page.nextCursor === null) {
return Effect.succeed({ files, truncated: false });
}
// A change nobody could read in one sitting is not worth a point of budget a page:
// the boxes on screen still work, and the count says it is partial rather than lying.
return pagesLeft <= 1
? Effect.succeed({ files, truncated: true })
: read(page.nextCursor, files, pagesLeft - 1);
}),
);
return read(null, [], FILES_VIEWED_MAX_PAGES);
},

setPullRequestFilesViewed: (input) => {
const mutation = buildSetFilesViewedGraphQlMutation(input.files);
if (mutation === null) return Effect.void;
return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe(
Effect.flatMap((pullRequestId) =>
graphql({
cwd: input.cwd,
host: input.host,
query: mutation.query,
variables: { pullRequestId, ...mutation.variables },
}),
),
);
},

setReviewThreadResolution: (input) =>
graphql({
cwd: input.cwd,
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/pullRequest/GitHubPullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const CAPABILITIES: PullRequestCapabilities = {
updateMethods: ["merge", "rebase"],
search: true,
reactions: true,
viewedFiles: true,
review: {
inlineComment: true,
reply: true,
Expand Down Expand Up @@ -399,6 +400,12 @@ export const make = Effect.gen(function* () {
getDiffFileContents: (input) =>
cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))),

getFilesViewed: (input) =>
cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))),

setFilesViewed: (input) =>
cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))),

listReviewerCandidates: (input) =>
cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))),

Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/pullRequest/PullRequestProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
PullRequestChecksState,
PullRequestCheck,
PullRequestComment,
PullRequestFileViewed,
PullRequestCommit,
PullRequestInvolvement,
PullRequestLabel,
Expand Down Expand Up @@ -201,6 +202,12 @@ export interface ProviderDiffFileContents {
readonly newContents: string;
}

export interface ProviderFilesViewed {
readonly files: ReadonlyArray<PullRequestFileViewed>;
/** The host has more files than were read, so the ones missing here are not "unviewed". */
readonly truncated: boolean;
}

export interface ProviderRepositoryRef {
readonly cwd: string;
/** Provider-native repository identity, e.g. `owner/repo` or `group/subgroup/project`. */
Expand Down Expand Up @@ -355,6 +362,29 @@ export interface PullRequestProviderApi {
},
) => Effect.Effect<ProviderDiffFileContents, PullRequestProviderError>;

/**
* Which files the reader has already cleared. Only called when `capabilities.viewedFiles` is
* true, and read apart from the patch: a host that reports this at all reports it on a clock of
* its own, moving with every press rather than with every push.
*/
readonly getFilesViewed?: (
input: ProviderRepositoryRef & { readonly number: number },
) => Effect.Effect<ProviderFilesViewed, PullRequestProviderError>;

/**
* Clears files, or puts them back. Only called when `capabilities.viewedFiles` is true.
*
* Takes several at once because that is how they are pressed. A provider whose host has no
* bulk form still owes one round trip for the batch rather than one per file, since the point
* of gathering them here is that the host is asked once.
*/
readonly setFilesViewed?: (
input: ProviderRepositoryRef & {
readonly number: number;
readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
},
) => Effect.Effect<void, PullRequestProviderError>;

readonly runAction: (
input: ProviderRepositoryRef & {
readonly number: number;
Expand Down
Loading
Loading