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. */
265269const 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+
311327export 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 ,
0 commit comments