1import * as Schema from "effect/Schema";
2import * as HttpServerRespondable from "effect/http/HttpServerRespondable";
3import * as HttpServerResponse from "effect/http/HttpServerResponse";
4
5import {
6 IsoDateTime,
7 NonNegativeInt,
8 PositiveInt,
9 ProjectId,
10 ThreadId,
11 TrimmedNonEmptyString,
12} from "./baseSchemas.ts";
13import { SourceControlProviderKind } from "./sourceControl.ts";
14
15export const PullRequestInvolvement = Schema.Literals(["all", "reviewing", "authored"]);
16export type PullRequestInvolvement = typeof PullRequestInvolvement.Type;
17
18export const PullRequestState = Schema.Literals(["open", "closed", "merged"]);
19export type PullRequestState = typeof PullRequestState.Type;
20
21/**
22 * What a listing asks for, which is the three states a change request can be in plus the option
23 * to span them. Separate from `PullRequestState` because a change request is never "all" — only
24 * a request for one can be.
25 */
26export const PullRequestListState = Schema.Literals(["all", "open", "closed", "merged"]);
27export type PullRequestListState = typeof PullRequestListState.Type;
28
29/** Where a review stands overall, as a host that summarises its reviews reports it. */
30export const PullRequestReviewDecision = Schema.Literals([
31 "approved",
32 "changes-requested",
33 "review-required",
34]);
35export type PullRequestReviewDecision = typeof PullRequestReviewDecision.Type;
36
37/** One qualifier's value, bounded because it is written into a host's own search query. */
38const PullRequestQualifierValue = TrimmedNonEmptyString.check(Schema.isMaxLength(200));
39const PullRequestQualifierValues = Schema.Array(PullRequestQualifierValue).check(
40 Schema.isMaxLength(10),
41);
42
43/**
44 * Narrowings beyond state and involvement, each absent by default — an absent field filters
45 * nothing, which is what every listing did before there were any. Optional as a whole so a page
46 * and a server of different ages still speak to each other.
47 *
48 * `checks` is host-side only: no row carries its own check state, so a host that cannot match it
49 * answers unnarrowed rather than the page pretending to know.
50 */
51export const PullRequestListFilters = Schema.Struct({
52 draft: Schema.optional(Schema.Literals(["only", "hide"])),
53 review: Schema.optional(
54 Schema.Literals(["approved", "changes-requested", "review-required", "none"]),
55 ),
56 checks: Schema.optional(Schema.Literals(["passing", "failing"])),
57 /**
58 * Labels as GitHub's own search reads them: each group is one `label:` qualifier, a row must
59 * satisfy every group, and a group holding several names is satisfied by any one of them —
60 * `label:size:S,size:XS` finds either size. Typed rather than picked, since a project's labels
61 * are its own and no menu can list them. Bounded because each becomes a host search qualifier.
62 */
63 labels: Schema.optional(Schema.Array(PullRequestQualifierValues).check(Schema.isMaxLength(10))),
64 excludedLabels: Schema.optional(PullRequestQualifierValues),
65 /** One login, as `author:` names it. */
66 author: Schema.optional(PullRequestQualifierValue),
67});
68export type PullRequestListFilters = typeof PullRequestListFilters.Type;
69
70/** The one-glyph summary of a change request's checks, as its list row wears it. */
71export const PullRequestChecksState = Schema.Literals(["passing", "failing", "pending"]);
72export type PullRequestChecksState = typeof PullRequestChecksState.Type;
73
74export const PullRequestMergeability = Schema.Literals(["mergeable", "conflicting", "unknown"]);
75export type PullRequestMergeability = typeof PullRequestMergeability.Type;
76
77export const PullRequestMergeMethod = Schema.Literals(["merge", "squash", "rebase"]);
78export type PullRequestMergeMethod = typeof PullRequestMergeMethod.Type;
79
80export const PullRequestAction = Schema.Literals([
81 "merge",
82 "ready",
83 "draft",
84 "close",
85 "reopen",
86 "delete-branch",
87 /** Bring the base branch's commits into this one, which is what unblocks a stale branch. */
88 "update-branch",
89 /**
90 * Hand the merge to the host: it waits for whatever it requires — a green pipeline, the
91 * approvals, an unblocked branch — and merges without anybody coming back. Separate from
92 * `merge` because the two answer different questions. `merge` is refused outright by a host
93 * that is not ready yet, and this one is for exactly that moment.
94 */
95 "enable-auto-merge",
96 /** Take the standing instruction back, which leaves the change request where it was. */
97 "disable-auto-merge",
98 /** Open a new change request that reverses a merged one. */
99 "revert",
100 /** Allow Actions workflows from a fork pull request to begin running. */
101 "approve-workflows",
102]);
103export type PullRequestAction = typeof PullRequestAction.Type;
104
105/**
106 * How a stale branch catches up with its base: a merge commit, or a rebase onto it. The two are
107 * the host's own choices, not this page's — GitHub offers both and refuses a rebase it cannot
108 * replay, so what is offered comes from the host and what is allowed comes from the viewer.
109 */
110export const PullRequestUpdateMethod = Schema.Literals(["merge", "rebase"]);
111export type PullRequestUpdateMethod = typeof PullRequestUpdateMethod.Type;
112
113/**
114 * Where the branch stands against the base it would merge into. Separate from `mergeability`,
115 * which answers a different question: a branch can be behind and still merge cleanly, and that
116 * pairing — out of date, no conflicts — is the one an update button exists for.
117 *
118 * "unknown" where the host was not asked or could not say, which is every host but GitHub and
119 * every pull request whose head repository could not be compared.
120 */
121export const PullRequestBaseComparison = Schema.Literals(["up-to-date", "behind", "unknown"]);
122export type PullRequestBaseComparison = typeof PullRequestBaseComparison.Type;
123
124export const PullRequestActor = Schema.Struct({
125 /** Present when the host identifies an automated account. */
126 isBot: Schema.optional(Schema.Boolean),
127 login: TrimmedNonEmptyString,
128 name: Schema.NullOr(Schema.String),
129 /** Null where a host does not report one, which is what the initials fall back to. */
130 avatarUrl: Schema.NullOr(Schema.String),
131});
132export type PullRequestActor = typeof PullRequestActor.Type;
133
134export const PullRequestLabel = Schema.Struct({
135 name: TrimmedNonEmptyString,
136 color: Schema.NullOr(Schema.String),
137});
138export type PullRequestLabel = typeof PullRequestLabel.Type;
139
140export const PullRequestCheckStatus = Schema.Literals([
141 "pending",
142 "action-required",
143 "success",
144 "failure",
145 "skipped",
146 "neutral",
147 "cancelled",
148]);
149export type PullRequestCheckStatus = typeof PullRequestCheckStatus.Type;
150
151export const PullRequestCheck = Schema.Struct({
152 name: TrimmedNonEmptyString,
153 status: PullRequestCheckStatus,
154 description: Schema.NullOr(Schema.String),
155 url: Schema.NullOr(Schema.String),
156 completedAt: Schema.optional(IsoDateTime),
157 /** The base branch requires this check to merge. Absent where the host does not say. */
158 required: Schema.optional(Schema.Boolean),
159});
160export type PullRequestCheck = typeof PullRequestCheck.Type;
161
162/**
163 * The reactions a remark can carry. GitHub's eight, which is also what the picker offers: GitLab
164 * accepts any emoji as an award, and the ones outside this set are read as nothing rather than
165 * shown under a name no other host would recognise.
166 */
167export const PullRequestReactionContent = Schema.Literals([
168 "thumbs-up",
169 "thumbs-down",
170 "laugh",
171 "hooray",
172 "confused",
173 "heart",
174 "rocket",
175 "eyes",
176]);
177export type PullRequestReactionContent = typeof PullRequestReactionContent.Type;
178
179/** One reaction and everyone behind it, which is what the hover on a reaction pill says. */
180export const PullRequestReaction = Schema.Struct({
181 content: PullRequestReactionContent,
182 count: PositiveInt,
183 /**
184 * Who reacted, as far as the host named them. A host that reports fewer names than it counts
185 * leaves the rest out, so this is never longer than `count` and may be shorter. When
186 * `viewerHasReacted` is true, the viewer's own login is left out of this list — the page names
187 * them "You" instead — but `count` still counts them along with everyone else.
188 */
189 actors: Schema.Array(TrimmedNonEmptyString),
190 /** The signed-in account is one of them, so pressing the pill takes the reaction back. */
191 viewerHasReacted: Schema.Boolean,
192});
193export type PullRequestReaction = typeof PullRequestReaction.Type;
194
195export const PullRequestCommentKind = Schema.Literals([
196 "issue-comment",
197 "review-comment",
198 "review",
199]);
200export type PullRequestCommentKind = typeof PullRequestCommentKind.Type;
201
202export const PullRequestComment = Schema.Struct({
203 id: TrimmedNonEmptyString,
204 kind: PullRequestCommentKind,
205 author: Schema.NullOr(PullRequestActor),
206 body: Schema.String,
207 createdAt: IsoDateTime,
208 editedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
209 url: Schema.NullOr(Schema.String),
210 path: Schema.NullOr(Schema.String),
211 reviewState: Schema.NullOr(Schema.String),
212 /** Absent from a host with no reactions at all, which is a different thing from none on this. */
213 reactions: Schema.optional(Schema.Array(PullRequestReaction)),
214 /**
215 * The host's own answer to whether this reader may rewrite this remark. Absent where the host
216 * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both
217 * here and in its review thread; a host that sets this sets it on both copies alike.
218 */
219 canEdit: Schema.optional(Schema.Boolean),
220});
221export type PullRequestComment = typeof PullRequestComment.Type;
222
223/**
224 * Which file a diff line belongs to: `left` is the version before the change, `right` the
225 * version after. A comment has to name one, because a unified diff shows both at once and the
226 * same line number means two different lines.
227 */
228export const PullRequestDiffSide = Schema.Literals(["left", "right"]);
229export type PullRequestDiffSide = typeof PullRequestDiffSide.Type;
230
231/** What submitting a review says about the change, beyond the words in it. */
232export const PullRequestReviewVerdict = Schema.Literals(["comment", "approve", "request-changes"]);
233export type PullRequestReviewVerdict = typeof PullRequestReviewVerdict.Type;
234
235export const PullRequestThreadComment = Schema.Struct({
236 id: TrimmedNonEmptyString,
237 author: Schema.NullOr(PullRequestActor),
238 body: Schema.String,
239 createdAt: IsoDateTime,
240 editedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
241 url: Schema.NullOr(Schema.String),
242 reactions: Schema.optional(Schema.Array(PullRequestReaction)),
243 /**
244 * The host's own answer to whether this reader may rewrite this remark. Absent where the host
245 * doesn't say, which leaves the page to guess from authorship. A remark on a line appears both
246 * here and in its review thread; a host that sets this sets it on both copies alike.
247 */
248 canEdit: Schema.optional(Schema.Boolean),
249});
250export type PullRequestThreadComment = typeof PullRequestThreadComment.Type;
251
252/**
253 * A conversation anchored to a line of the diff. The detail carries these alongside `comments`
254 * rather than instead of them: the timeline wants one flat, chronological list, and the diff
255 * wants whole threads pinned to their line — the same remarks, read two different ways.
256 */
257export const PullRequestReviewThread = Schema.Struct({
258 id: TrimmedNonEmptyString,
259 path: TrimmedNonEmptyString,
260 /** Null when the host anchors the thread to a file rather than to a line. */
261 line: Schema.NullOr(PositiveInt),
262 side: PullRequestDiffSide,
263 isResolved: Schema.Boolean,
264 /**
265 * The line the thread was written against is no longer in the diff, so it cannot be shown
266 * against the code. Such a thread is listed separately rather than pinned to the wrong line.
267 */
268 isOutdated: Schema.Boolean,
269 comments: Schema.Array(PullRequestThreadComment),
270 /** Host-reported total, when this thread was read in pages. */
271 commentCount: Schema.optional(NonNegativeInt),
272 /** Opaque cursor for the next comment page. Absent once this thread is whole. */
273 nextCommentsCursor: Schema.optional(TrimmedNonEmptyString),
274 /**
275 * The host's own answer to whether this reader may resolve this thread. It can only narrow the
276 * reader's repository-wide `resolve` permission, never widen it. Absent where the host
277 * doesn't say per thread.
278 */
279 canResolve: Schema.optional(Schema.Boolean),
280});
281export type PullRequestReviewThread = typeof PullRequestReviewThread.Type;
282
283/**
284 * Whether a reviewer is a person or a group of them the host addresses as one. GitHub is the only
285 * host here that takes a review request for a team; the others name individuals only, so nothing
286 * they report is ever anything but a user.
287 */
288export const PullRequestReviewerKind = Schema.Literals(["user", "team"]);
289export type PullRequestReviewerKind = typeof PullRequestReviewerKind.Type;
290
291/**
292 * Somebody a review may be asked of. Carries the actor fields so the menu shows the same face and
293 * handle the rest of the page does, and a team wears them too — its slug stands in for a login.
294 */
295export const PullRequestReviewerCandidate = Schema.Struct({
296 ...PullRequestActor.fields,
297 /**
298 * How the host addresses this reviewer when a review is requested, which is not always the
299 * handle it shows: GitHub takes a login or a team slug, GitLab a numeric user id, Bitbucket an
300 * account uuid. Opaque to the page, which sends back whatever the candidate arrived with.
301 */
302 id: TrimmedNonEmptyString,
303 kind: PullRequestReviewerKind,
304 /** A review has already been asked of them, so pressing them takes the request back. */
305 isRequested: Schema.Boolean,
306});
307export type PullRequestReviewerCandidate = typeof PullRequestReviewerCandidate.Type;
308
309export const PullRequestReviewerCandidateList = Schema.Struct({
310 /** Never includes the author: nobody is asked to review their own change request. */
311 candidates: Schema.Array(PullRequestReviewerCandidate),
312 /**
313 * The host has more people with access than the read asked for, so somebody missing from the
314 * menu may still be somebody this change request can be sent to. Nothing is wrong with the
315 * list; it is simply not all of it.
316 */
317 truncated: Schema.Boolean,
318});
319export type PullRequestReviewerCandidateList = typeof PullRequestReviewerCandidateList.Type;
320
321/** A label the repository defines, with whether this change request already wears it. */
322export const PullRequestLabelCandidate = Schema.Struct({
323 ...PullRequestLabel.fields,
324 description: Schema.NullOr(Schema.String),
325 isApplied: Schema.Boolean,
326});
327export type PullRequestLabelCandidate = typeof PullRequestLabelCandidate.Type;
328
329export const PullRequestLabelCandidateList = Schema.Struct({
330 candidates: Schema.Array(PullRequestLabelCandidate),
331 /** The repository defines more labels than the read asked for; the list is not all of them. */
332 truncated: Schema.Boolean,
333});
334export type PullRequestLabelCandidateList = typeof PullRequestLabelCandidateList.Type;
335
336export const PullRequestCommit = Schema.Struct({
337 oid: TrimmedNonEmptyString,
338 messageHeadline: Schema.String,
339 committedDate: IsoDateTime,
340 /** Per-commit line counts where the host can return them without a request per commit. */
341 additions: Schema.optional(NonNegativeInt),
342 deletions: Schema.optional(NonNegativeInt),
343 /**
344 * Everyone the host attributes the commit to. Optional because older servers and hosts that
345 * do not expose commit authors still produce a useful timeline entry.
346 */
347 authors: Schema.optional(Schema.Array(PullRequestActor)),
348});
349export type PullRequestCommit = typeof PullRequestCommit.Type;
350
351/**
352 * What a host can do with a review, which is where the four differ most. GitLab has no way to
353 * say "changes requested", and Azure DevOps exposes no diff through its CLI, so it has no lines
354 * to write against at all.
355 */
356export const PullRequestReviewCapabilities = Schema.Struct({
357 /** A new comment can be anchored to a line of the diff. */
358 inlineComment: Schema.Boolean,
359 /** An existing thread can be replied to. */
360 reply: Schema.Boolean,
361 /** A thread can be marked resolved, and unresolved again. */
362 resolve: Schema.Boolean,
363 /** The verdicts a submitted review can carry. Empty means reviews cannot be submitted. */
364 verdicts: Schema.Array(PullRequestReviewVerdict),
365});
366export type PullRequestReviewCapabilities = typeof PullRequestReviewCapabilities.Type;
367
368/**
369 * What a host lets be rewritten after it has been posted. The two are separate because a host can
370 * take one without the other: Azure DevOps takes a new title and description through the same
371 * command that closes a pull request, and has no way to post a remark here at all — so nothing it
372 * shows in a conversation can be rewritten either.
373 */
374export const PullRequestEditCapabilities = Schema.Struct({
375 /** The change request's own title and description can be rewritten. */
376 changeRequest: Schema.Boolean,
377 /** A remark can be rewritten by whoever wrote it. */
378 comment: Schema.Boolean,
379});
380export type PullRequestEditCapabilities = typeof PullRequestEditCapabilities.Type;
381
382/**
383 * What a host can do about who reviews. The two are independent: a host can take a request without
384 * publishing who may receive one, which is Azure DevOps.
385 */
386export const PullRequestReviewerCapabilities = Schema.Struct({
387 /** A review can be asked of somebody, and the request taken back. */
388 request: Schema.Boolean,
389 /**
390 * The people who may be asked can be listed. False where the host has no such list to give,
391 * which leaves the page to take a name rather than offer a menu — a guessed list would be worse,
392 * because a name missing from it reads as a name that cannot be asked.
393 */
394 listCandidates: Schema.Boolean,
395});
396export type PullRequestReviewerCapabilities = typeof PullRequestReviewerCapabilities.Type;
397
398/**
399 * Who remembers which files a reader has cleared. `host` is the host's own record, so the marks
400 * are the ones its web UI shows and a review can be carried on from either side. `environment` is
401 * this server's record, for a host that keeps none anything outside one browser can read.
402 */
403export const PullRequestViewedFilesStore = Schema.Literals(["host", "environment"]);
404export type PullRequestViewedFilesStore = typeof PullRequestViewedFilesStore.Type;
405
406/**
407 * What a provider can actually do, so a surface can hide what is missing rather than offer an
408 * action that would fail. Every provider fills this in for itself; nothing is assumed.
409 *
410 * Hosts differ more than they look: Azure DevOps exposes no patch through its CLI, and
411 * Bitbucket has no endpoint that reopens a declined pull request. Both would otherwise be dead
412 * buttons.
413 */
414export const PullRequestCapabilities = Schema.Struct({
415 /** A unified patch can be fetched for the change request. */
416 diff: Schema.Boolean,
417 /** A comment can be posted, and the conversation read back. */
418 comment: Schema.Boolean,
419 /** The actions this host can carry out; anything absent is never offered. */
420 actions: Schema.Array(PullRequestAction),
421 /** Merge strategies the provider itself offers, before repository settings narrow them. */
422 mergeMethods: Schema.Array(PullRequestMergeMethod),
423 deleteBranch: Schema.optional(Schema.Boolean),
424 /**
425 * How this host can bring a stale branch up to date. Absent where it cannot at all, which is
426 * every host that has not said otherwise — so a provider that says nothing offers nothing.
427 */
428 updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)),
429 /**
430 * The host can narrow a listing by free text. False means it answers unnarrowed and whoever
431 * asked has to do the narrowing — which is a different promise, so the page is told rather
432 * than left to show every change request on that host as a search result.
433 */
434 search: Schema.Boolean,
435 /**
436 * Reactions can be read from a remark, and added to one or taken back. One flag for both:
437 * neither host here reports reactions it will not also take, and a surface that could show a
438 * pill it may never press is a surface offering nothing. Optional for the same reason as
439 * `updateMethods` and `edit`: a server that says nothing about reactions has none, which is
440 * what every server before this field was.
441 */
442 reactions: Schema.optional(Schema.Boolean),
443 /**
444 * Where the reader's own marks are kept, or absent where they are kept nowhere and the
445 * checkbox is not offered at all. Optional for the same reason as `reactions`.
446 */
447 viewedFiles: Schema.optional(PullRequestViewedFilesStore),
448 review: PullRequestReviewCapabilities,
449 reviewers: PullRequestReviewerCapabilities,
450 /**
451 * What can be rewritten after the fact. Optional so a page and a server of different ages still
452 * speak to each other: a server that says nothing about rewriting is one that cannot, which is
453 * what every server before this one was.
454 */
455 edit: Schema.optional(PullRequestEditCapabilities),
456 /**
457 * The host keeps stacks of change requests as objects of its own, so a linked thread can show
458 * the stack the host shows. Absent means chains are only ever inferred from base branches.
459 */
460 stacks: Schema.optional(Schema.Boolean),
461 stackActions: Schema.optional(Schema.Boolean),
462 /**
463 * The repository's labels can be listed, and one put on a change request or taken off it.
464 * Optional for the same reason `edit` is: a server that says nothing about labels has no way
465 * to change them, which is what every server before this field was.
466 */
467 labels: Schema.optional(Schema.Boolean),
468});
469export type PullRequestCapabilities = typeof PullRequestCapabilities.Type;
470
471/**
472 * What the signed-in account may do with this change request, which is a different question from
473 * `capabilities`: that says what the host is able to do at all, and this says whether this viewer
474 * is allowed to ask for it. A host that merges pull requests still will not let a stranger merge
475 * one, so a control belongs on the page only where the two agree.
476 *
477 * A permission the host reports nothing about is granted rather than withheld. Hiding a control
478 * from someone who may in fact use it leaves them no way through and no reason given, while
479 * offering one they may not use ends in the host's own refusal — which at least says why.
480 */
481export const PullRequestViewerPermissions = Schema.Struct({
482 /** May request remote stack rebases, including when this layer is already current. */
483 stackRebase: Schema.optional(Schema.Boolean),
484 /** Which of the actions this viewer may take; anything absent is theirs to look at only. */
485 actions: Schema.Array(PullRequestAction),
486 /** This viewer may write a remark: a comment, a reply, or a note against a line. */
487 comment: Schema.Boolean,
488 /** This viewer may mark a review conversation resolved, and unresolved again. */
489 resolve: Schema.Boolean,
490 /** The verdicts this viewer may submit a review with. Empty means they may not review. */
491 verdicts: Schema.Array(PullRequestReviewVerdict),
492 /** This viewer may ask somebody for a review, and take the request back again. */
493 requestReviewers: Schema.Boolean,
494 /**
495 * The ways this viewer may bring the branch up to date, narrowed from what the host offers.
496 * Absent or empty means they may not, which is also what a host with no such action says.
497 */
498 updateMethods: Schema.optional(Schema.Array(PullRequestUpdateMethod)),
499 /**
500 * This viewer may put a label on the change request, and take one off. Absent is granted, like
501 * every permission here; the capability beside it is what decides whether a label can be
502 * changed on this host at all.
503 */
504 labels: Schema.optional(Schema.Boolean),
505 /**
506 * This viewer may rewrite the change request's title and description. Absent where the host
507 * doesn't say, which leaves the page to guess from authorship and merge access.
508 */
509 editChangeRequest: Schema.optional(Schema.Boolean),
510});
511export type PullRequestViewerPermissions = typeof PullRequestViewerPermissions.Type;
512
513export const PullRequestMergeCapabilities = Schema.Struct({
514 merge: Schema.Boolean,
515 squash: Schema.Boolean,
516 rebase: Schema.Boolean,
517});
518export type PullRequestMergeCapabilities = typeof PullRequestMergeCapabilities.Type;
519
520export const PullRequestStackMembership = Schema.Struct({
521 number: PositiveInt,
522 position: PositiveInt,
523 size: PositiveInt,
524 base: TrimmedNonEmptyString,
525});
526export type PullRequestStackMembership = typeof PullRequestStackMembership.Type;
527
528export const PullRequestListEntry = Schema.Struct({
529 stack: Schema.optional(PullRequestStackMembership),
530 provider: SourceControlProviderKind,
531 /**
532 * The host below which `repository` is addressed, so the same provider kind can serve more
533 * than one account — github.com and a GitHub Enterprise install are different identities.
534 */
535 host: TrimmedNonEmptyString,
536 projectId: ProjectId,
537 projectTitle: TrimmedNonEmptyString,
538 repository: TrimmedNonEmptyString,
539 number: PositiveInt,
540 title: TrimmedNonEmptyString,
541 url: TrimmedNonEmptyString,
542 author: Schema.NullOr(PullRequestActor),
543 headBranch: TrimmedNonEmptyString,
544 baseBranch: TrimmedNonEmptyString,
545 state: PullRequestState,
546 isDraft: Schema.Boolean,
547 mergeability: PullRequestMergeability,
548 /**
549 * Zero where the host has not been asked for the counts yet, which on GitHub is every row a
550 * listing hands over: they are read afterwards, through `PullRequestListStatsInput`. Zero is
551 * what a host that reports no counts at all has always sent, and what the page already draws
552 * as no stat rather than as an empty change.
553 */
554 additions: NonNegativeInt,
555 deletions: NonNegativeInt,
556 createdAt: IsoDateTime,
557 updatedAt: IsoDateTime,
558 /** Server epoch milliseconds when the provider read started; preserved on cache hits. */
559 observedAt: Schema.optional(Schema.Finite),
560 viewerReviewRequested: Schema.Boolean,
561 labels: Schema.Array(PullRequestLabel),
562 /** Absent where the host does not summarise its reviews, which is every host but GitHub. */
563 reviewDecision: Schema.optional(PullRequestReviewDecision),
564 /** Absent where the host reports no check rollup, or the change request has no checks. */
565 checksState: Schema.optional(PullRequestChecksState),
566});
567export type PullRequestListEntry = typeof PullRequestListEntry.Type;
568
569/**
570 * Where each repository a listing already reached carries on from, keyed `"<host> <repository>"`
571 * — which is how a listing tells two repositories apart, since the same `owner/repo` exists on
572 * github.com and on an Enterprise install at once.
573 *
574 * Each value is opaque: only the provider that issued one knows what it means, and the page hands
575 * back exactly what it was given rather than composing one.
576 */
577export const PullRequestListCursors = Schema.Record(
578 TrimmedNonEmptyString,
579 // Bounded because it arrives from the page and is unfolded into a host's own filter.
580 TrimmedNonEmptyString.check(Schema.isMaxLength(4096)),
581);
582export type PullRequestListCursors = typeof PullRequestListCursors.Type;
583
584export const PullRequestListInput = Schema.Struct({
585 state: PullRequestListState,
586 involvement: Schema.optional(PullRequestInvolvement),
587 filters: Schema.optional(PullRequestListFilters),
588 projectId: Schema.optional(ProjectId),
589 /**
590 * Only these projects, for a client that assigns each shared repository to one of its
591 * connections and asks the others to stay quiet about it. Absent means every project, which
592 * is what every listing asked for before there were several connections to spread across.
593 */
594 projectIds: Schema.optional(Schema.Array(ProjectId).check(Schema.isMaxLength(100))),
595 /**
596 * Narrows the listing to one host, named as the host itself rather than as its provider kind:
597 * github.com and a GitHub Enterprise install are two accounts, and a kind cannot tell them
598 * apart. Absent means every host the workspace has.
599 */
600 host: Schema.optional(TrimmedNonEmptyString),
601 /** Rows to return per repository, which with a continuation is rows per slice. */
602 limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 500 }))),
603 /**
604 * Carry on from an answer already on the page rather than read the listing again. Only the
605 * repositories named here are read, one slice each — which is what makes a further page cost
606 * one slice rather than every repository over again at a larger size. A repository the page has
607 * enough of is simply left out.
608 *
609 * Absent asks for the listing from the top, which is what raising `limit` alone has always
610 * done and still does.
611 */
612 cursors: Schema.optional(PullRequestListCursors),
613 /**
614 * Free text the hosts themselves are asked to match, rather than a filter over the rows that
615 * have already arrived: a listing only ever holds a page per repository, so a search that
616 * never leaves the client can only find what happened to be loaded. What a match means is the
617 * host's own business — GitHub reads a body, GitLab a description — and a host with no text
618 * filter answers unnarrowed rather than pretending.
619 *
620 * Bounded because it travels into a CLI argument and a query string, and no host makes
621 * anything of a search term this long.
622 */
623 query: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))),
624});
625export type PullRequestListInput = typeof PullRequestListInput.Type;
626
627/**
628 * A host the workspace has projects on, and whether it can be read right now. Drives the host
629 * switcher and explains projects the list leaves out.
630 *
631 * One per host rather than per provider kind, because signing in is a question about the host:
632 * a workspace can hold repositories on github.com and on a GitHub Enterprise install at once,
633 * and the two are separate accounts that succeed and fail independently.
634 */
635export const PullRequestProviderSummary = Schema.Struct({
636 host: TrimmedNonEmptyString,
637 kind: SourceControlProviderKind,
638 /** False where a search has to be applied to the rows after they arrive. */
639 searchesOnHost: Schema.Boolean,
640 /**
641 * The actions this host can carry out, so a row can offer them before its detail is read.
642 * Absent from servers older than this field; clients then offer what they always did.
643 */
644 actions: Schema.optional(Schema.Array(PullRequestAction)),
645 projectCount: PositiveInt,
646 /** False when the provider's CLI or credentials are missing, with `detail` saying which. */
647 configured: Schema.Boolean,
648 detail: Schema.NullOr(TrimmedNonEmptyString),
649});
650export type PullRequestProviderSummary = typeof PullRequestProviderSummary.Type;
651
652/** One project whose repository could not be read; healthy projects still return entries. */
653export const PullRequestListProjectError = Schema.Struct({
654 projectId: ProjectId,
655 projectTitle: TrimmedNonEmptyString,
656 message: TrimmedNonEmptyString,
657});
658export type PullRequestListProjectError = typeof PullRequestListProjectError.Type;
659
660export const PullRequestListResult = Schema.Struct({
661 /**
662 * The signed-in account per host, which is what involvement filtering compares. Keyed by
663 * host rather than by provider kind: two GitHub hosts are two accounts. A host that could
664 * not be read is absent rather than present-and-undefined, because an open-keyed record
665 * cannot carry an optional value through the JSON codec.
666 */
667 viewers: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString),
668 providers: Schema.Array(PullRequestProviderSummary),
669 /**
670 * By update, newest first, across every repository this answer covers. A page that appends a
671 * continuation sorts what it then holds the same way: a repository's next slice is older than
672 * that repository's last row, but can still be newer than another repository's.
673 */
674 entries: Schema.Array(PullRequestListEntry),
675 errors: Schema.Array(PullRequestListProjectError),
676 /** At least one repository hit the per-repository listing cap. */
677 truncated: Schema.Boolean,
678 /**
679 * Where each repository carries on, to be sent straight back as `cursors`. A repository is
680 * absent from this once it has nothing more to give, and also where its host could not be asked
681 * in an order a continuation means anything in — so an empty record beside `truncated` means
682 * more rows are only reachable by raising `limit`, the way they always were.
683 */
684 nextCursors: PullRequestListCursors,
685});
686export type PullRequestListResult = typeof PullRequestListResult.Type;
687
688/**
689 * Addresses one pull request for reads and writes. `projectId` picks the checkout the host
690 * CLI runs in and, when its repository matches, the credentials; `host` lets the server
691 * route a pull request from another repository through any project on the same host
692 * (a frontend project's thread linking a backend PR). Absent `host` means "the project's
693 * own host", which is every reference from before thread links became host-level.
694 */
695export const PullRequestRef = Schema.Struct({
696 projectId: ProjectId,
697 host: Schema.optional(TrimmedNonEmptyString),
698 /** Refuse a routed operation unless this GitHub account still owns the active credential. */
699 expectedAccountId: Schema.optional(TrimmedNonEmptyString),
700 /** Let another environment answer when this one's cached response has expired. */
701 allowStale: Schema.optional(Schema.Boolean),
702 repository: TrimmedNonEmptyString,
703 number: PositiveInt,
704});
705export type PullRequestRef = typeof PullRequestRef.Type;
706
707/** Account discovery never needs the originating project's private repository metadata. */
708export const PullRequestRoutingIdentityInput = Schema.Struct({
709 host: TrimmedNonEmptyString,
710});
711export type PullRequestRoutingIdentityInput = typeof PullRequestRoutingIdentityInput.Type;
712
713export const PullRequestRoutingIdentityResult = Schema.Struct({
714 accountId: TrimmedNonEmptyString,
715 host: TrimmedNonEmptyString,
716 provider: Schema.Literal("github"),
717 viewer: TrimmedNonEmptyString,
718});
719export type PullRequestRoutingIdentityResult = typeof PullRequestRoutingIdentityResult.Type;
720
721export const PullRequestRoutingResult = Schema.Struct({
722 accountId: TrimmedNonEmptyString,
723 host: TrimmedNonEmptyString,
724 provider: SourceControlProviderKind,
725 viewer: TrimmedNonEmptyString,
726 projectTitle: TrimmedNonEmptyString,
727 workspaceRoot: TrimmedNonEmptyString,
728});
729export type PullRequestRoutingResult = typeof PullRequestRoutingResult.Type;
730
731export const PullRequestLinkedThreadsResult = Schema.Struct({
732 threads: Schema.Array(
733 Schema.Struct({
734 id: ThreadId,
735 projectId: ProjectId,
736 title: Schema.String,
737 archivedAt: Schema.NullOr(IsoDateTime),
738 }),
739 ),
740});
741export type PullRequestLinkedThreadsResult = typeof PullRequestLinkedThreadsResult.Type;
742
743/** The complete hover card, without checks, permissions, or branch comparisons. */
744export const PullRequestPreview = Schema.Struct({
745 projectId: ProjectId,
746 repository: TrimmedNonEmptyString,
747 number: PositiveInt,
748 title: TrimmedNonEmptyString,
749 url: TrimmedNonEmptyString,
750 author: Schema.NullOr(PullRequestActor),
751 state: PullRequestState,
752 isDraft: Schema.Boolean,
753 createdAt: IsoDateTime,
754});
755export type PullRequestPreview = typeof PullRequestPreview.Type;
756
757/**
758 * The small live shape a linked thread needs. Keeping it separate from detail means a sidebar
759 * status check never loads permissions, repository settings, checks, or base comparison data.
760 */
761export const PullRequestSummary = Schema.Struct({
762 provider: SourceControlProviderKind,
763 projectId: ProjectId,
764 repository: TrimmedNonEmptyString,
765 number: PositiveInt,
766 title: TrimmedNonEmptyString,
767 url: TrimmedNonEmptyString,
768 state: PullRequestState,
769 /** Present when the host says the open pull request is still a draft. */
770 isDraft: Schema.optional(Schema.Boolean),
771 headBranch: TrimmedNonEmptyString,
772 baseBranch: TrimmedNonEmptyString,
773 closedAt: Schema.optional(Schema.NullOr(Schema.String)),
774 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
775 updatedAt: IsoDateTime,
776 /** Server epoch milliseconds when the provider read started; preserved on cache hits. */
777 observedAt: Schema.optional(Schema.Finite),
778 author: Schema.optional(Schema.NullOr(PullRequestActor)),
779 additions: Schema.optional(NonNegativeInt),
780 deletions: Schema.optional(NonNegativeInt),
781 changedFiles: Schema.optional(NonNegativeInt),
782 reviewDecision: Schema.optional(Schema.NullOr(PullRequestReviewDecision)),
783 checksState: Schema.optional(Schema.NullOr(PullRequestChecksState)),
784 mergeability: Schema.optional(PullRequestMergeability),
785 /** Null when the host says the pull request is in no stack; absent when the read did not ask. */
786 stack: Schema.optional(Schema.NullOr(PullRequestStackMembership)),
787});
788export type PullRequestSummary = typeof PullRequestSummary.Type;
789
790/** The host-native stack a pull request belongs to, in the thread link's shape. */
791export const PullRequestStack = Schema.Struct({
792 id: TrimmedNonEmptyString,
793 number: PositiveInt,
794 url: TrimmedNonEmptyString,
795 base: TrimmedNonEmptyString,
796 layers: Schema.Array(
797 Schema.Struct({
798 number: PositiveInt,
799 title: Schema.optional(Schema.String),
800 isDraft: Schema.optional(Schema.Boolean),
801 headSha: Schema.optional(TrimmedNonEmptyString),
802 headBranch: TrimmedNonEmptyString,
803 state: PullRequestState,
804 }),
805 ),
806});
807export type PullRequestStack = typeof PullRequestStack.Type;
808
809/**
810 * One row's line counts, read after the listing rather than inside it. On GitHub the pair is
811 * 40-60% of the wall clock of the search that answers the whole page — measured over twelve
812 * repositories, 7.1s with it and 4.0s without — for two small numbers at the end of a row.
813 *
814 * So the listing answers without them and the page asks for them next, which is what puts the
815 * rows on screen at the speed of everything else on them.
816 */
817export const PullRequestDiffStat = Schema.Struct({
818 projectId: ProjectId,
819 repository: TrimmedNonEmptyString,
820 number: PositiveInt,
821 additions: NonNegativeInt,
822 deletions: NonNegativeInt,
823});
824export type PullRequestDiffStat = typeof PullRequestDiffStat.Type;
825
826/**
827 * The rows whose line counts are wanted, which is the rows the page is showing.
828 *
829 * Bounded at the largest listing a page can hold, since each ref becomes a lookup of its own on
830 * the host.
831 */
832export const PullRequestListStatsInput = Schema.Struct({
833 refs: Schema.Array(PullRequestRef).check(Schema.isMaxLength(500)),
834});
835export type PullRequestListStatsInput = typeof PullRequestListStatsInput.Type;
836
837/**
838 * Only the rows that could be answered for. A host with no batched stat read of its own — every
839 * one but GitHub, which all report the counts in the listing itself — is simply absent here, and
840 * the numbers its listing already carried stand.
841 */
842export const PullRequestListStatsResult = Schema.Struct({
843 stats: Schema.Array(PullRequestDiffStat),
844});
845export type PullRequestListStatsResult = typeof PullRequestListStatsResult.Type;
846
847/**
848 * Forget what the server has cached, so the next read asks the host. With a reference it
849 * forgets that one change request's detail and diff; without one it forgets the listings.
850 * A separate request rather than a flag on the reads, so an explicit "refresh" one person
851 * presses is the only thing that spends host requests — every ordinary read shares.
852 */
853export const PullRequestInvalidateInput = Schema.Struct({
854 reference: Schema.optional(PullRequestRef),
855 /** Refresh review progress across routed environments without discarding the patch. */
856 filesViewedOnly: Schema.optional(Schema.Boolean),
857});
858export type PullRequestInvalidateInput = typeof PullRequestInvalidateInput.Type;
859
860/**
861 * The state a read routed to another environment saw, reported to the environment the read was
862 * for so its thread links can catch up. A hint only: that environment confirms with the host
863 * before writing anything.
864 */
865export const PullRequestReportStateInput = Schema.Struct({
866 reference: PullRequestRef,
867 state: PullRequestState,
868});
869export type PullRequestReportStateInput = typeof PullRequestReportStateInput.Type;
870
871export const PullRequestDetail = Schema.Struct({
872 provider: SourceControlProviderKind,
873 capabilities: PullRequestCapabilities,
874 /** What this viewer may do, which `capabilities` says nothing about. Both narrow the page. */
875 viewerPermissions: PullRequestViewerPermissions,
876 projectId: ProjectId,
877 projectTitle: TrimmedNonEmptyString,
878 workspaceRoot: TrimmedNonEmptyString,
879 repository: TrimmedNonEmptyString,
880 number: PositiveInt,
881 title: TrimmedNonEmptyString,
882 body: Schema.String,
883 url: TrimmedNonEmptyString,
884 author: Schema.NullOr(PullRequestActor),
885 state: PullRequestState,
886 isDraft: Schema.Boolean,
887 mergeability: PullRequestMergeability,
888 additions: NonNegativeInt,
889 deletions: NonNegativeInt,
890 changedFiles: NonNegativeInt,
891 headBranch: TrimmedNonEmptyString,
892 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
893 /** The head commit, where the host reports it with the detail. */
894 headSha: Schema.optional(TrimmedNonEmptyString),
895 baseBranch: TrimmedNonEmptyString,
896 createdAt: IsoDateTime,
897 updatedAt: IsoDateTime,
898 /** Server epoch milliseconds when the provider read started; preserved on cache hits. */
899 observedAt: Schema.optional(Schema.Finite),
900 mergedAt: Schema.NullOr(IsoDateTime),
901 closedAt: Schema.NullOr(IsoDateTime),
902 reviewers: Schema.Array(PullRequestActor),
903 labels: Schema.Array(PullRequestLabel),
904 checks: Schema.Array(PullRequestCheck),
905 mergeCapabilities: PullRequestMergeCapabilities,
906 /**
907 * Who the host says the reader is, which is the one thing a conversation cannot be read without
908 * to tell the reader's own remarks from everybody else's — and rewriting a remark is offered
909 * only where the two names agree. Absent where the host could not say, which offers nothing
910 * rather than everything.
911 */
912 viewer: Schema.optional(TrimmedNonEmptyString),
913 /**
914 * Where the branch stands against its base. Optional so a host that cannot compare says
915 * nothing rather than claiming the branch is current — the page shows a banner only where the
916 * answer is "behind", and silence is not that answer.
917 */
918 baseComparison: Schema.optional(PullRequestBaseComparison),
919 /** How many commits the base is ahead by, where the host counted them. */
920 behindBy: Schema.optional(NonNegativeInt),
921 /**
922 * Whether the host is already armed to merge this on its own. Absent where the host does not
923 * report it, which is not the same as off: a page that reads silence as "not armed" offers to
924 * arm something that is already armed, and a second arming is a write nobody asked for.
925 */
926 autoMergeEnabled: Schema.optional(Schema.Boolean),
927 /** The strategy the host will use for an armed auto-merge, where it reports one. */
928 autoMergeMethod: Schema.optional(PullRequestMergeMethod),
929 /** GitHub Actions runs on this head commit that are waiting for a maintainer's approval. */
930 workflowApprovalsRequired: Schema.optional(NonNegativeInt),
931});
932export type PullRequestDetail = typeof PullRequestDetail.Type;
933
934export const PullRequestChecks = Schema.Struct({
935 state: PullRequestState,
936 checks: Schema.Array(PullRequestCheck),
937});
938export type PullRequestChecks = typeof PullRequestChecks.Type;
939
940/**
941 * The slower, conversation-shaped half of a change request. It is read independently from the
942 * core detail so a host with a deeply paginated review history cannot hold the title, body,
943 * checks, and actions off screen. `author` and `reviewers` are optional enrichments: GitHub's
944 * conversation query carries avatars and completed reviewers that its basic detail does not.
945 */
946export const PullRequestActivity = Schema.Struct({
947 author: Schema.optional(Schema.NullOr(PullRequestActor)),
948 reviewers: Schema.optional(Schema.Array(PullRequestActor)),
949 comments: Schema.Array(PullRequestComment),
950 /**
951 * How many remarks the host itself counts in the conversation, which is the number a surface
952 * showing a count has to show: `comments` carries what was read, and a reader who can see 225
953 * on the host is not reassured by a page that says 10. Never less than `comments` holds, and
954 * equal to it wherever the host was read to the end.
955 */
956 commentCount: NonNegativeInt,
957 /**
958 * The read stopped at a bound of its own before the host ran out, so `comments` holds less
959 * than `commentCount` counts. Nothing else: a conversation read whole is never truncated,
960 * however long it is.
961 */
962 commentsTruncated: Schema.Boolean,
963 /** Whether the thread listing itself is incomplete, apart from pages within a thread. */
964 reviewThreadsTruncated: Schema.optional(Schema.Boolean),
965 reviewThreads: Schema.Array(PullRequestReviewThread),
966 commits: Schema.Array(PullRequestCommit),
967 /**
968 * The change request's own reactions — the ones on its description, which every host counts
969 * against the change request itself rather than against a remark in the conversation.
970 */
971 reactions: Schema.optional(Schema.Array(PullRequestReaction)),
972});
973export type PullRequestActivity = typeof PullRequestActivity.Type;
974
975/** The complete detail shape after the independently loaded activity has been applied. */
976export const PullRequestDetailView = Schema.Struct({
977 ...PullRequestDetail.fields,
978 ...PullRequestActivity.fields,
979 // A composed view always has the core identity fields, even when the activity did not enrich
980 // them. Re-declare them as required after the activity's optional overrides.
981 author: Schema.NullOr(PullRequestActor),
982 reviewers: Schema.Array(PullRequestActor),
983});
984export type PullRequestDetailView = typeof PullRequestDetailView.Type;
985
986/**
987 * A diff arrives a slice at a time, because a large change is more than any host will hand over
988 * at once — GitHub refuses outright past 300 files — and more than a viewer can lay out in one
989 * go. A slice is a whole number of files, never a file cut in half, so each one parses on its
990 * own and the reader can start on the first while the rest is still coming.
991 */
992export const PullRequestDiffInput = Schema.Struct({
993 ...PullRequestRef.fields,
994 /**
995 * Where to carry on from. Absent asks for the first slice. Opaque to the reader: each host
996 * counts its files its own way, and only the provider that issued one knows what it means.
997 */
998 cursor: Schema.optional(TrimmedNonEmptyString),
999 /**
1000 * One commit of the change rather than the whole of it. A large pull request is easier to
1001 * read a commit at a time, and the author's own commits are the boundaries they chose.
1002 */
1003 commit: Schema.optional(TrimmedNonEmptyString),
1004});
1005export type PullRequestDiffInput = typeof PullRequestDiffInput.Type;
1006
1007/** Real line counts for a file whose hunks the host withheld from the patch. */
1008export const PullRequestOmittedFileStat = Schema.Struct({
1009 path: TrimmedNonEmptyString,
1010 additions: Schema.Number,
1011 deletions: Schema.Number,
1012});
1013export type PullRequestOmittedFileStat = typeof PullRequestOmittedFileStat.Type;
1014
1015export const PullRequestDiffResult = Schema.Struct({
1016 patch: Schema.String,
1017 /**
1018 * Something inside this slice could not be shown — a binary file, or a hunk the host declined
1019 * to inline. Not the same as there being more slices, which `nextCursor` answers.
1020 */
1021 truncated: Schema.Boolean,
1022 /** Where the next slice starts, or null once the diff is whole. */
1023 nextCursor: Schema.NullOr(TrimmedNonEmptyString),
1024 /**
1025 * The host's own counts for the files whose hunks it withheld, so a file the patch cannot
1026 * show still reports what changed instead of a zero the diff never had.
1027 */
1028 omittedFileStats: Schema.optional(Schema.Array(PullRequestOmittedFileStat)),
1029});
1030export type PullRequestDiffResult = typeof PullRequestDiffResult.Type;
1031
1032/** The complete old and new files Pierre needs to open omitted context in a host-backed patch. */
1033export const PullRequestDiffFileContentsInput = Schema.Struct({
1034 ...PullRequestRef.fields,
1035 /** One commit's own comparison; absent means the whole change request. */
1036 commit: Schema.optional(TrimmedNonEmptyString),
1037 changeType: Schema.Literals(["change", "rename-pure", "rename-changed", "new", "deleted"]),
1038 oldPath: TrimmedNonEmptyString,
1039 newPath: TrimmedNonEmptyString,
1040});
1041export type PullRequestDiffFileContentsInput = typeof PullRequestDiffFileContentsInput.Type;
1042
1043export const PullRequestDiffFileContentsResult = Schema.Struct({
1044 oldContents: Schema.String,
1045 newContents: Schema.String,
1046});
1047export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type;
1048
1049/**
1050 * Bounded because a path arrives from a client rather than from the host: one element of a write
1051 * batch could otherwise carry a megabyte into a SQL statement or a GraphQL field.
1052 */
1053const MAX_FILE_PATH_LENGTH = 4096;
1054/**
1055 * Not trimmed: a leading or trailing space is a legal part of a file's name, and the mark is
1056 * keyed by the name the host gave, so trimming files it under a name nothing else uses.
1057 */
1058const FilePath = Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(MAX_FILE_PATH_LENGTH));
1059
1060/**
1061 * Where one file of a change request stands with the person reading it. `dismissed` is the file
1062 * that was cleared and has since been pushed to, which is worth telling a reader apart from
1063 * `unviewed`: this file and not the other forty is the one that moved.
1064 */
1065export const PullRequestFileViewedState = Schema.Literals(["unviewed", "viewed", "dismissed"]);
1066export type PullRequestFileViewedState = typeof PullRequestFileViewedState.Type;
1067
1068export const PullRequestFileViewed = Schema.Struct({
1069 path: FilePath,
1070 state: PullRequestFileViewedState,
1071});
1072export type PullRequestFileViewed = typeof PullRequestFileViewed.Type;
1073
1074/**
1075 * Which files of a change request the reader has cleared. Its own read rather than a field on the
1076 * patch: a patch changes when somebody pushes and is cached by the minute, this changes on every
1077 * press, so one read would have to be wrong for the other to be right.
1078 */
1079export const PullRequestFilesViewedResult = Schema.Struct({
1080 /** Only the files the host reported a state for. A file missing from this list is unviewed. */
1081 files: Schema.Array(PullRequestFileViewed),
1082 /** The host had more files than were read, so the count is short and says so. */
1083 truncated: Schema.Boolean,
1084});
1085export type PullRequestFilesViewedResult = typeof PullRequestFilesViewedResult.Type;
1086
1087/**
1088 * How many presses one write carries. Every element is a statement of its own inside one
1089 * transaction here, or a field of its own in one GraphQL document on GitHub. Matched to what a
1090 * read of the marks carries, so a client cannot write more of them than it can ever read back.
1091 */
1092const MAX_FILES_VIEWED_PRESSES = 500;
1093
1094/**
1095 * Files to clear, or to put back. Several at once because a reader working down a diff ticks
1096 * boxes far faster than a host answers, so a burst is gathered into one request.
1097 */
1098export const PullRequestSetFilesViewedInput = Schema.Struct({
1099 ...PullRequestRef.fields,
1100 files: Schema.Array(
1101 Schema.Struct({
1102 path: FilePath,
1103 viewed: Schema.Boolean,
1104 }),
1105 ).check(Schema.isMaxLength(MAX_FILES_VIEWED_PRESSES)),
1106});
1107export type PullRequestSetFilesViewedInput = typeof PullRequestSetFilesViewedInput.Type;
1108
1109export const PullRequestStackHead = Schema.Struct({
1110 number: PositiveInt,
1111 headSha: TrimmedNonEmptyString,
1112});
1113export type PullRequestStackHead = typeof PullRequestStackHead.Type;
1114
1115export const PullRequestActionInput = Schema.Struct({
1116 /** Native stack scope; only send to environments advertising pullRequestStackActions. */
1117 stackNumber: Schema.optional(PositiveInt),
1118 expectedStackHeads: Schema.optional(Schema.Array(PullRequestStackHead)),
1119 ...PullRequestRef.fields,
1120 action: PullRequestAction,
1121 /**
1122 * Which strategy the merge uses, read for `merge` and for `enable-auto-merge` alike — a merge
1123 * the host performs later is still a merge, and the strategy is chosen when it is armed rather
1124 * than at the moment it happens. Absent means the host's own default.
1125 */
1126 mergeMethod: Schema.optional(PullRequestMergeMethod),
1127 /** Only read for `update-branch`, where absent means the host's own default. */
1128 updateMethod: Schema.optional(PullRequestUpdateMethod),
1129});
1130export type PullRequestActionInput = typeof PullRequestActionInput.Type;
1131
1132// Not trimmed: the body is markdown, where leading spaces open a code block and two trailing
1133// spaces are a line break. GitHub rejects bodies past 65536 characters, so that bound is
1134// enforced here to keep oversized payloads off the wire and out of subprocess plumbing; the
1135// service rejects a body that is only whitespace.
1136const CommentBody = Schema.String.check(Schema.isNonEmpty()).check(Schema.isMaxLength(65_536));
1137
1138export const PullRequestCommentInput = Schema.Struct({
1139 ...PullRequestRef.fields,
1140 body: CommentBody,
1141});
1142export type PullRequestCommentInput = typeof PullRequestCommentInput.Type;
1143
1144/**
1145 * A change request's own words rewritten: its title, its description, or both. Each is optional
1146 * because the page rewrites them apart — a title corrected on its own must not carry a description
1147 * nobody opened — and the service refuses a request that carries neither.
1148 *
1149 * The title is bounded far past what any host takes, GitHub refusing one past 256 characters, so
1150 * an oversized one is turned away before it reaches a subprocess. The body is not trimmed for the
1151 * same reason a comment is not: it is markdown, where leading spaces open a code block. An empty
1152 * one is allowed, which is how a description is cleared.
1153 */
1154export const PullRequestUpdateInput = Schema.Struct({
1155 ...PullRequestRef.fields,
1156 title: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(1024))),
1157 body: Schema.optional(Schema.String.check(Schema.isMaxLength(65_536))),
1158});
1159export type PullRequestUpdateInput = typeof PullRequestUpdateInput.Type;
1160
1161/**
1162 * A remark already posted, rewritten by whoever wrote it. The kind travels beside the id because
1163 * an id alone does not say which endpoint addresses it: GitHub rewrites a comment on the
1164 * conversation and a comment on a line through two different mutations.
1165 *
1166 * A review's own summary is not among them. The hosts disagree about what one even is — the entry
1167 * Bitbucket shows is a vote this page renders as a remark, with no words behind it to rewrite —
1168 * and a control that worked on one host and failed on another is worse than one that is not there.
1169 */
1170export const PullRequestCommentUpdateInput = Schema.Struct({
1171 ...PullRequestRef.fields,
1172 commentId: TrimmedNonEmptyString,
1173 kind: Schema.Literals(["issue-comment", "review-comment"]),
1174 body: CommentBody,
1175});
1176export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type;
1177
1178/** The coordinates of one line in a pull request diff. */
1179export const PullRequestReviewPosition = Schema.Union([
1180 Schema.Struct({
1181 kind: Schema.Literal("added"),
1182 newLine: PositiveInt,
1183 }),
1184 Schema.Struct({
1185 kind: Schema.Literal("deleted"),
1186 oldLine: PositiveInt,
1187 }),
1188 Schema.Struct({
1189 kind: Schema.Literal("context"),
1190 oldLine: PositiveInt,
1191 newLine: PositiveInt,
1192 /** Which copy of an unchanged line the reviewer selected in a split diff. */
1193 side: PullRequestDiffSide,
1194 }),
1195]);
1196export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type;
1197
1198/** One remark in a review that has not been sent yet, anchored to a line of the diff. */
1199export const PullRequestReviewCommentDraft = Schema.Struct({
1200 path: TrimmedNonEmptyString,
1201 /**
1202 * What the file was called before the change, sent only when it differs. GitLab resolves a
1203 * position against both sides of the diff, so a comment on a renamed file needs both names;
1204 * the hosts that address a comment by one path ignore this.
1205 */
1206 oldPath: Schema.optional(TrimmedNonEmptyString),
1207 position: PullRequestReviewPosition,
1208 body: CommentBody,
1209});
1210export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type;
1211
1212/**
1213 * A whole review, sent in one go. The line comments travel with the verdict rather than being
1214 * posted as they are written, so a half-finished review is never visible to anyone else — and
1215 * so hosts with no notion of a pending review behave the same as the ones that have it.
1216 */
1217export const PullRequestSubmitReviewInput = Schema.Struct({
1218 ...PullRequestRef.fields,
1219 verdict: PullRequestReviewVerdict,
1220 /** The review's own words. May be empty, which is how an approval with no remarks is sent. */
1221 body: Schema.String.check(Schema.isMaxLength(65_536)),
1222 comments: Schema.Array(PullRequestReviewCommentDraft),
1223});
1224export type PullRequestSubmitReviewInput = typeof PullRequestSubmitReviewInput.Type;
1225
1226export const PullRequestThreadCommentsInput = Schema.Struct({
1227 ...PullRequestRef.fields,
1228 threadId: TrimmedNonEmptyString,
1229 cursor: TrimmedNonEmptyString,
1230});
1231export type PullRequestThreadCommentsInput = typeof PullRequestThreadCommentsInput.Type;
1232
1233export const PullRequestThreadCommentsResult = Schema.Struct({
1234 comments: Schema.Array(PullRequestThreadComment),
1235 nextCursor: Schema.NullOr(TrimmedNonEmptyString),
1236});
1237export type PullRequestThreadCommentsResult = typeof PullRequestThreadCommentsResult.Type;
1238
1239export const PullRequestThreadReplyInput = Schema.Struct({
1240 ...PullRequestRef.fields,
1241 threadId: TrimmedNonEmptyString,
1242 body: CommentBody,
1243});
1244export type PullRequestThreadReplyInput = typeof PullRequestThreadReplyInput.Type;
1245
1246export const PullRequestThreadResolutionInput = Schema.Struct({
1247 ...PullRequestRef.fields,
1248 threadId: TrimmedNonEmptyString,
1249 resolved: Schema.Boolean,
1250});
1251export type PullRequestThreadResolutionInput = typeof PullRequestThreadResolutionInput.Type;
1252
1253/**
1254 * Reacting and taking the reaction back are one operation with `reacted` turned around, which is
1255 * what pressing the same pill twice is.
1256 */
1257export const PullRequestReactionInput = Schema.Struct({
1258 ...PullRequestRef.fields,
1259 /**
1260 * Which remark to react to, as it arrived in the conversation. Absent reacts to the change
1261 * request itself, which is where its description's reactions live — the id a host uses for
1262 * that is its own to work out.
1263 */
1264 subjectId: Schema.optional(TrimmedNonEmptyString),
1265 content: PullRequestReactionContent,
1266 reacted: Schema.Boolean,
1267});
1268export type PullRequestReactionInput = typeof PullRequestReactionInput.Type;
1269
1270/**
1271 * Asking for a review and taking the request back are one operation with `requested` turned
1272 * around, and asking again somebody who has already reviewed is what a re-request is — so that is
1273 * the same operation once more rather than a third one.
1274 */
1275export const PullRequestReviewerRequestInput = Schema.Struct({
1276 ...PullRequestRef.fields,
1277 /**
1278 * Who, as the candidate list named them. Bounded because every host bounds it as well — GitHub
1279 * refuses past fifteen — and because these travel into a request body a page composed.
1280 */
1281 reviewers: Schema.Array(
1282 Schema.Struct({ id: TrimmedNonEmptyString, kind: PullRequestReviewerKind }),
1283 ).check(Schema.isMinLength(1), Schema.isMaxLength(25)),
1284 requested: Schema.Boolean,
1285});
1286export type PullRequestReviewerRequestInput = typeof PullRequestReviewerRequestInput.Type;
1287
1288/**
1289 * Putting a label on and taking it off are one operation with `applied` turned around, which is
1290 * what pressing the same row in the menu twice is. Named by the label's own name, which is how
1291 * GitHub addresses one.
1292 */
1293export const PullRequestLabelChangeInput = Schema.Struct({
1294 ...PullRequestRef.fields,
1295 labels: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1), Schema.isMaxLength(25)),
1296 applied: Schema.Boolean,
1297});
1298export type PullRequestLabelChangeInput = typeof PullRequestLabelChangeInput.Type;
1299
1300export const PullRequestUnavailableReason = Schema.Literals([
1301 "cli-missing",
1302 "cli-unauthenticated",
1303 "provider-unsupported",
1304]);
1305export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.Type;
1306
1307/**
1308 * What each host needs before it can be read, so a failure names the fix rather than the
1309 * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and
1310 * Bitbucket they mean "no credential" and "a refused credential", not a missing tool.
1311 */
1312const PROVIDER_REQUIREMENT = new Map<
1313 string,
1314 { readonly missing: string; readonly unauthenticated: string }
1315>([
1316 [
1317 "github",
1318 {
1319 missing:
1320 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.",
1321 unauthenticated:
1322 "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.",
1323 },
1324 ],
1325 [
1326 "forgejo",
1327 {
1328 missing:
1329 "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.",
1330 unauthenticated:
1331 "Authenticate your Forgejo or Gitea server with `fj --host <server-url> auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.",
1332 },
1333 ],
1334 [
1335 "gitlab",
1336 {
1337 missing:
1338 "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.",
1339 unauthenticated: "GitLab CLI is not authenticated. Run `glab auth login` and retry.",
1340 },
1341 ],
1342 [
1343 "azure-devops",
1344 {
1345 missing:
1346 "Azure CLI (`az`) with the Azure DevOps extension is required. Install `az`, then run `az extension add --name azure-devops`.",
1347 unauthenticated: "Azure CLI is not signed in. Run `az login` and retry.",
1348 },
1349 ],
1350 [
1351 "bitbucket",
1352 {
1353 missing:
1354 "Bitbucket needs API credentials on the server. Add them in Settings → Source Control.",
1355 unauthenticated:
1356 "Bitbucket rejected the configured credentials. Check them in Settings → Source Control.",
1357 },
1358 ],
1359]);
1360
1361/**
1362 * The host a project's repository is addressed below. `canonicalKey` is the normalized remote,
1363 * `host/owner/repo`, so its first segment is the host; the provider kind stands in when there is
1364 * no key to read, which keeps one bucket per kind for identities recorded before it existed.
1365 *
1366 * Shared between the server and the page so both bucket a workspace the same way — the page
1367 * knows its hosts before the listing answers, and the two must agree on what they are called.
1368 */
1369export function pullRequestHostOf(
1370 identity:
1371 | {
1372 readonly canonicalKey?: string | undefined;
1373 readonly locator?: { readonly remoteUrl: string } | undefined;
1374 }
1375 | null
1376 | undefined,
1377 kind: SourceControlProviderKind,
1378): string {
1379 if (kind === "forgejo") {
1380 try {
1381 const remote = new URL(identity?.locator?.remoteUrl ?? "");
1382 if (remote.protocol === "http:" || remote.protocol === "https:")
1383 return remote.host.toLowerCase();
1384 } catch {
1385 // SSH remotes retain their canonical host; the CLI resolves their web endpoint.
1386 }
1387 }
1388 const host = identity?.canonicalKey?.split("/")[0]?.trim();
1389 return host === undefined || host.length === 0 ? kind : host.toLowerCase();
1390}
1391
1392/**
1393 * `author:me` names whoever is signed in to the host being read, GitHub's `@me` spelled either
1394 * way. A host's own search would answer it, but the page and the server both re-check an author
1395 * against the row's login, so the name is resolved before either comparison rather than being
1396 * matched as the literal login "me".
1397 *
1398 * Null viewer where the host has not said who the reader is; the filter then stands as typed,
1399 * which is the same answer a search for a user named "me" would give.
1400 *
1401 * Shared between the server and the page so both narrow by the same login.
1402 */
1403export function resolvePullRequestAuthorFilter(
1404 author: string,
1405 viewer: string | null | undefined,
1406): string {
1407 const trimmed = author.trim();
1408 if (!/^@?me$/i.test(trimmed)) return trimmed;
1409 return viewer === null || viewer === undefined || viewer.trim().length === 0 ? trimmed : viewer;
1410}
1411
1412/**
1413 * What a host needs before it can be read, as a sentence to show wherever that host is reported
1414 * as unusable — the whole page when nothing can be read, and one entry in the host switcher when
1415 * only that host cannot. Null when the reason is not about setting a host up.
1416 */
1417export function pullRequestProviderRequirement(
1418 provider: SourceControlProviderKind,
1419 reason: PullRequestUnavailableReason,
1420): string | null {
1421 const requirement = PROVIDER_REQUIREMENT.get(provider);
1422 if (requirement === undefined) return null;
1423 switch (reason) {
1424 case "cli-missing":
1425 return requirement.missing;
1426 case "cli-unauthenticated":
1427 return requirement.unauthenticated;
1428 case "provider-unsupported":
1429 return null;
1430 }
1431}
1432
1433/**
1434 * The feature is switched off entirely. The message is derived from `reason` and the host
1435 * rather than from whatever the CLI printed, so it stays a stable sentence the UI can show
1436 * as-is; the underlying failure travels in `cause` (absent for `provider-unsupported`, which
1437 * has none).
1438 */
1439export class PullRequestUnavailableError extends Schema.TaggedError<PullRequestUnavailableError>()(
1440 "PullRequestUnavailableError",
1441 {
1442 reason: PullRequestUnavailableReason,
1443 provider: Schema.optional(SourceControlProviderKind),
1444 cause: Schema.optional(Schema.Defect()),
1445 },
1446 { httpApiStatus: 503 },
1447) {
1448 [HttpServerRespondable.symbol]() {
1449 return HttpServerResponse.schemaJson(PullRequestUnavailableError)(this, { status: 503 });
1450 }
1451
1452 override get message(): string {
1453 const requirement =
1454 this.provider === undefined ? undefined : PROVIDER_REQUIREMENT.get(this.provider);
1455 switch (this.reason) {
1456 case "cli-missing":
1457 return (
1458 requirement?.missing ?? "The tool this host is read through is not installed or set up."
1459 );
1460 case "cli-unauthenticated":
1461 return requirement?.unauthenticated ?? "This host has no working credentials.";
1462 case "provider-unsupported":
1463 return "Change requests cannot be browsed for this project's host yet.";
1464 }
1465 }
1466}
1467
1468export class PullRequestOperationError extends Schema.TaggedError<PullRequestOperationError>()(
1469 "PullRequestOperationError",
1470 {
1471 operation: Schema.String,
1472 detail: TrimmedNonEmptyString,
1473 reason: Schema.optional(Schema.Literal("not-found")),
1474 cause: Schema.optional(Schema.Defect()),
1475 },
1476 { httpApiStatus: 502 },
1477) {
1478 [HttpServerRespondable.symbol]() {
1479 return HttpServerResponse.schemaJson(PullRequestOperationError)(this, { status: 502 });
1480 }
1481
1482 override get message(): string {
1483 return `Pull request operation ${this.operation} failed: ${this.detail}`;
1484 }
1485}