Skip to content

Commit c05617d

Browse files
authored
feat(web): pull request files can be marked as viewed (#23)
2 parents 64a7302 + 56719db commit c05617d

24 files changed

Lines changed: 1390 additions & 18 deletions

‎apps/server/src/auth/RpcAuthorization.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const RPC_REQUIRED_SCOPES = {
5858
[WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope,
5959
[WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope,
6060
[WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope,
61+
[WS_METHODS.pullRequestsFilesViewed]: AuthOrchestrationReadScope,
6162
[WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope,
6263
[WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope,
6364
[WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope,
@@ -66,6 +67,7 @@ export const RPC_REQUIRED_SCOPES = {
6667
[WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope,
6768
[WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope,
6869
[WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope,
70+
[WS_METHODS.pullRequestsSetFilesViewed]: AuthOrchestrationOperateScope,
6971
// Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
7072
// client pressing refresh must not be told it may not look again.
7173
[WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,

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

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2603,4 +2603,149 @@ layer("GitHubPullRequestCli.layer", (it) => {
26032603
]);
26042604
}),
26052605
);
2606+
2607+
it.effect("reads every page of viewed files, and says so when there are too many", () =>
2608+
Effect.gen(function* () {
2609+
const page = (index: number, hasNextPage: boolean) =>
2610+
Effect.succeed(
2611+
output(
2612+
// @effect-diagnostics-next-line preferSchemaOverJson:off
2613+
JSON.stringify({
2614+
data: {
2615+
repository: {
2616+
pullRequest: {
2617+
files: {
2618+
pageInfo: { hasNextPage, endCursor: `cursor-${index}` },
2619+
nodes: [
2620+
{ path: `src/file${index}.ts`, viewerViewedState: "VIEWED" },
2621+
{ path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" },
2622+
],
2623+
},
2624+
},
2625+
},
2626+
},
2627+
}),
2628+
),
2629+
);
2630+
mockedExecute
2631+
.mockReturnValueOnce(page(0, true))
2632+
.mockReturnValueOnce(page(1, true))
2633+
.mockReturnValueOnce(page(2, false));
2634+
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2635+
2636+
const viewed = yield* cli.getPullRequestFilesViewed({
2637+
cwd: "/w",
2638+
repository: "acme/web",
2639+
host: "github.com",
2640+
number: 7,
2641+
});
2642+
2643+
assert.strictEqual(mockedExecute.mock.calls.length, 3);
2644+
// The first page asks from the start; each one after it carries the cursor before it.
2645+
assert.isFalse(callAt(0).args.some((arg) => arg.startsWith("after=")));
2646+
expect(callAt(1).args).toContain("after=cursor-0");
2647+
expect(callAt(2).args).toContain("after=cursor-1");
2648+
assert.isFalse(viewed.truncated);
2649+
expect(viewed.files.map((file) => [file.path, file.state])).toEqual([
2650+
["src/file0.ts", "viewed"],
2651+
["src/other0.ts", "unviewed"],
2652+
["src/file1.ts", "viewed"],
2653+
["src/other1.ts", "unviewed"],
2654+
["src/file2.ts", "viewed"],
2655+
["src/other2.ts", "unviewed"],
2656+
]);
2657+
}),
2658+
);
2659+
2660+
it.effect("stops paging viewed files rather than following a change without end", () =>
2661+
Effect.gen(function* () {
2662+
mockedExecute.mockReturnValue(
2663+
Effect.succeed(
2664+
output(
2665+
// @effect-diagnostics-next-line preferSchemaOverJson:off
2666+
JSON.stringify({
2667+
data: {
2668+
repository: {
2669+
pullRequest: {
2670+
files: {
2671+
pageInfo: { hasNextPage: true, endCursor: "cursor" },
2672+
nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }],
2673+
},
2674+
},
2675+
},
2676+
},
2677+
}),
2678+
),
2679+
),
2680+
);
2681+
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2682+
2683+
const viewed = yield* cli.getPullRequestFilesViewed({
2684+
cwd: "/w",
2685+
repository: "acme/web",
2686+
host: "github.com",
2687+
number: 7,
2688+
});
2689+
2690+
assert.strictEqual(mockedExecute.mock.calls.length, 5);
2691+
assert.isTrue(viewed.truncated);
2692+
assert.strictEqual(viewed.files.length, 5);
2693+
}),
2694+
);
2695+
2696+
it.effect("clears and restores a burst of files in one request", () =>
2697+
Effect.gen(function* () {
2698+
mockedExecute
2699+
// @effect-diagnostics-next-line preferSchemaOverJson:off
2700+
.mockReturnValueOnce(
2701+
Effect.succeed(
2702+
output(JSON.stringify({ data: { repository: { pullRequest: { id: "PR_1" } } } })),
2703+
),
2704+
)
2705+
.mockReturnValueOnce(Effect.succeed(output("{}")));
2706+
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2707+
2708+
yield* cli.setPullRequestFilesViewed({
2709+
cwd: "/w",
2710+
repository: "acme/web",
2711+
host: "github.com",
2712+
number: 7,
2713+
files: [
2714+
{ path: "src/a.ts", viewed: true },
2715+
{ path: "src/b.ts", viewed: false },
2716+
],
2717+
});
2718+
2719+
// One request to learn the pull request's node id, one for every press together.
2720+
assert.strictEqual(mockedExecute.mock.calls.length, 2);
2721+
// @effect-diagnostics-next-line preferSchemaOverJson:off
2722+
const sent = JSON.parse(callAt(1).stdin ?? "") as {
2723+
query: string;
2724+
variables: Record<string, string>;
2725+
};
2726+
expect(sent.query).toContain("f0: markFileAsViewed");
2727+
expect(sent.query).toContain("f1: unmarkFileAsViewed");
2728+
expect(sent.variables).toEqual({
2729+
pullRequestId: "PR_1",
2730+
path0: "src/a.ts",
2731+
path1: "src/b.ts",
2732+
});
2733+
}),
2734+
);
2735+
2736+
it.effect("asks the host nothing when nothing was pressed", () =>
2737+
Effect.gen(function* () {
2738+
const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
2739+
2740+
yield* cli.setPullRequestFilesViewed({
2741+
cwd: "/w",
2742+
repository: "acme/web",
2743+
host: "github.com",
2744+
number: 7,
2745+
files: [],
2746+
});
2747+
2748+
assert.strictEqual(mockedExecute.mock.calls.length, 0);
2749+
}),
2750+
);
26062751
});

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

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
resolvePullRequestAuthorFilter,
88
type PullRequestAction,
99
type PullRequestActor,
10+
type PullRequestFileViewed,
1011
type PullRequestInvolvement,
1112
type PullRequestListFilters,
1213
type PullRequestListState,
@@ -30,10 +31,12 @@ import {
3031
ADD_REACTION_GRAPHQL_MUTATION,
3132
buildReviewSubmissionJson,
3233
buildReviewerRequestJson,
34+
buildSetFilesViewedGraphQlMutation,
3335
decodeActorAvatarsJson,
3436
decodePullRequestActivityJson,
3537
decodePullRequestDetailJson,
3638
decodePullRequestFilesJson,
39+
decodePullRequestFilesViewedJson,
3740
decodePullRequestListJson,
3841
decodePullRequestNodeIdJson,
3942
decodePullRequestSearchJson,
@@ -53,6 +56,7 @@ import {
5356
decodeBaseComparisonJson,
5457
PULL_REQUEST_DETAIL_JSON_FIELDS,
5558
PULL_REQUEST_LIST_JSON_FIELDS,
59+
PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
5660
PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
5761
REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
5862
REMOVE_REACTION_GRAPHQL_MUTATION,
@@ -263,6 +267,12 @@ const PULL_REQUEST_FALLBACK_MAX_ROWS = 1_000;
263267

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

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

321+
export interface GitHubPullRequestFilesViewed {
322+
readonly files: ReadonlyArray<PullRequestFileViewed>;
323+
/** GitHub had more files than the page budget below would read. */
324+
readonly truncated: boolean;
325+
}
326+
311327
export class GitHubPullRequestCli extends Context.Service<
312328
GitHubPullRequestCli,
313329
{
@@ -415,6 +431,30 @@ export class GitHubPullRequestCli extends Context.Service<
415431
GitHubPullRequestCliError
416432
>;
417433

434+
/**
435+
* Which files of the pull request the signed-in account has cleared, and which of those have
436+
* been pushed to since. Read apart from the patch because GitHub only reports it over GraphQL,
437+
* and because the two answers go stale at completely different rates.
438+
*/
439+
readonly getPullRequestFilesViewed: (input: {
440+
readonly cwd: string;
441+
readonly repository: string;
442+
readonly host: string;
443+
readonly number: number;
444+
}) => Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestCliError>;
445+
446+
/**
447+
* Clears files, or puts them back, as one request. GitHub takes a single path per mutation,
448+
* so a burst is batched with aliases into one document rather than one subprocess per press.
449+
*/
450+
readonly setPullRequestFilesViewed: (input: {
451+
readonly cwd: string;
452+
readonly repository: string;
453+
readonly host: string;
454+
readonly number: number;
455+
readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>;
456+
}) => Effect.Effect<void, GitHubPullRequestCliError>;
457+
418458
readonly listReviewThreadComments: (input: {
419459
readonly cwd: string;
420460
readonly repository: string;
@@ -912,14 +952,25 @@ export const make = Effect.gen(function* () {
912952
readonly host: string;
913953
readonly query: string;
914954
readonly variables: Readonly<Record<string, string>>;
955+
/** What this write is expected to spend, for a batch that carries more than one mutation. */
956+
readonly estimatedCost?: number | undefined;
915957
}) =>
916-
github
917-
.execute({
918-
cwd: input.cwd,
919-
args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
920-
stdin: encodeGraphQlRequestJson({ query: input.query, variables: input.variables }),
921-
})
922-
.pipe(Effect.asVoid);
958+
graphQlBudget
959+
// A write is counted against the hourly budget but never held back by it, so the reserve
960+
// that pauses reads is measured against what has really been spent rather than against
961+
// reads alone. It cannot fail here: the budget only refuses reads.
962+
.query(input.host, input.query, { estimatedCost: input.estimatedCost ?? 1 })
963+
.pipe(
964+
Effect.orElseSucceed(() => input.query),
965+
Effect.flatMap((query) =>
966+
github.execute({
967+
cwd: input.cwd,
968+
args: ["api", "graphql", "--hostname", input.host, "--input", "-"],
969+
stdin: encodeGraphQlRequestJson({ query, variables: input.variables }),
970+
}),
971+
),
972+
Effect.asVoid,
973+
);
923974

924975
/** A GraphQL read whose answer is decoded, reporting a failure against the read that made it. */
925976
const graphqlRead = <A>(input: {
@@ -1764,6 +1815,59 @@ export const make = Effect.gen(function* () {
17641815
variables: { threadId: input.threadId, body: input.body },
17651816
}),
17661817

1818+
getPullRequestFilesViewed: (input) => {
1819+
const { owner, name } = parseRepositorySelector(input.repository);
1820+
const read = (
1821+
after: string | null,
1822+
collected: ReadonlyArray<PullRequestFileViewed>,
1823+
pagesLeft: number,
1824+
): Effect.Effect<GitHubPullRequestFilesViewed, GitHubPullRequestCliError> =>
1825+
graphqlRead({
1826+
cwd: input.cwd,
1827+
host: input.host,
1828+
operation: "getPullRequestFilesViewed",
1829+
variables: [
1830+
["-f", `owner=${owner}`],
1831+
["-f", `name=${name}`],
1832+
["-F", `number=${input.number}`],
1833+
...(after === null
1834+
? []
1835+
: ([["-f", `after=${after}`]] as ReadonlyArray<readonly [string, string]>)),
1836+
],
1837+
query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
1838+
decode: decodePullRequestFilesViewedJson,
1839+
}).pipe(
1840+
Effect.flatMap((page) => {
1841+
const files = [...collected, ...page.files];
1842+
if (page.nextCursor === null) {
1843+
return Effect.succeed({ files, truncated: false });
1844+
}
1845+
// A change nobody could read in one sitting is not worth a point of budget a page:
1846+
// the boxes on screen still work, and the count says it is partial rather than lying.
1847+
return pagesLeft <= 1
1848+
? Effect.succeed({ files, truncated: true })
1849+
: read(page.nextCursor, files, pagesLeft - 1);
1850+
}),
1851+
);
1852+
return read(null, [], FILES_VIEWED_MAX_PAGES);
1853+
},
1854+
1855+
setPullRequestFilesViewed: (input) => {
1856+
const mutation = buildSetFilesViewedGraphQlMutation(input.files);
1857+
if (mutation === null) return Effect.void;
1858+
return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe(
1859+
Effect.flatMap((pullRequestId) =>
1860+
graphql({
1861+
cwd: input.cwd,
1862+
host: input.host,
1863+
query: mutation.query,
1864+
variables: { pullRequestId, ...mutation.variables },
1865+
estimatedCost: input.files.length,
1866+
}),
1867+
),
1868+
);
1869+
},
1870+
17671871
setReviewThreadResolution: (input) =>
17681872
graphql({
17691873
cwd: input.cwd,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const CAPABILITIES: PullRequestCapabilities = {
3333
updateMethods: ["merge", "rebase"],
3434
search: true,
3535
reactions: true,
36+
viewedFiles: true,
3637
review: {
3738
inlineComment: true,
3839
reply: true,
@@ -399,6 +400,12 @@ export const make = Effect.gen(function* () {
399400
getDiffFileContents: (input) =>
400401
cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))),
401402

403+
getFilesViewed: (input) =>
404+
cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))),
405+
406+
setFilesViewed: (input) =>
407+
cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))),
408+
402409
listReviewerCandidates: (input) =>
403410
cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))),
404411

0 commit comments

Comments
 (0)