@kubohiroya/turbowarp-plus-editor-git 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.ja.md +13 -3
  2. package/README.md +12 -2
  3. package/dist/branch-button.d.ts +47 -0
  4. package/dist/change-marks.d.ts +8 -0
  5. package/dist/connection-settings.d.ts +3 -1
  6. package/dist/github-pull-requests.d.ts +2 -2
  7. package/dist/github-review-api.d.ts +112 -0
  8. package/dist/index.d.ts +29 -5
  9. package/dist/index.js +830 -303
  10. package/dist/messages.d.ts +109 -2
  11. package/dist/{project-asset-paths-nL5sV35A.js → project-asset-paths-DNaoWs3E.js} +2 -2
  12. package/dist/{project-comparison-DjMlwyp8.js → project-comparison-B_3QZ6M-.js} +1414 -33
  13. package/dist/project-source-diff.d.ts +5 -0
  14. package/dist/project-source-diff.js +4 -4
  15. package/dist/project-source-review.d.ts +6 -1
  16. package/dist/pull-request-panel.d.ts +2 -0
  17. package/dist/remote-operations.d.ts +1 -1
  18. package/dist/{repository-xIv0iZkj.js → repository-DXRZEtYk.js} +26 -10
  19. package/dist/repository.d.ts +7 -0
  20. package/dist/repository.js +1 -1
  21. package/dist/review-composer.d.ts +23 -0
  22. package/dist/review-submit.d.ts +144 -0
  23. package/dist/{smart-http-BmHEkE7K.js → smart-http-eARzlXHO.js} +101 -2
  24. package/dist/smart-http.d.ts +2 -0
  25. package/dist/smart-http.js +1 -1
  26. package/dist/style.css +51 -0
  27. package/package.json +1 -1
  28. package/src/branch-button.tsx +289 -0
  29. package/src/change-marks.ts +22 -0
  30. package/src/connection-settings.tsx +9 -2
  31. package/src/github-review-api.ts +298 -0
  32. package/src/index.tsx +322 -66
  33. package/src/messages.ts +80 -4
  34. package/src/project-source-diff.css +10 -0
  35. package/src/project-source-diff.tsx +6 -0
  36. package/src/project-source-review.tsx +33 -5
  37. package/src/pull-request-panel.tsx +27 -6
  38. package/src/remote-operations.ts +1 -1
  39. package/src/repository.ts +22 -8
  40. package/src/review-composer.tsx +166 -0
  41. package/src/review-submit.ts +519 -0
  42. package/src/smart-http.ts +2 -2
  43. package/src/style.css +41 -0
  44. package/dist/objectSpread2-Dkd68nJH.js +0 -101
@@ -0,0 +1,519 @@
1
+ import {GitHubReviewError, isReviewThreadPath, newReviewSubmitId, reviewEvents} from './github-review-api.js';
2
+ import type {GitHubReviewApi, GitHubReviewErrorCode, ReviewEvent} from './github-review-api.js';
3
+
4
+ /**
5
+ * Sending a pull request review through the GitHub connection, apart from React.
6
+ *
7
+ * With the proxy's GraphQL routes, a review is sent in three steps under one Review-Submit-Id: a pending review on the
8
+ * compared commit, one file-level thread on a project.source.json per script comment, then the submission. Until the
9
+ * last step nobody else can see anything, so a failure before it discards the pending review. An unknown outcome is
10
+ * never sent again on its own: the person checks it, and only then sends again. Without the GraphQL routes, the whole
11
+ * review goes in the body of one REST review. The draft stays in the browser until GitHub has the review.
12
+ */
13
+
14
+ /** One comment on a script, kept under the script's review key. */
15
+ export interface ReviewComment {
16
+ /** The project.source.json the script is in. */
17
+ file: string;
18
+ /** Names the stage or sprite, the script and its first block, in the words of the person writing. */
19
+ heading: string;
20
+ body: string;
21
+ }
22
+
23
+ /**
24
+ * A submission in progress. The ID is ours, not GitHub's. `threadKeys` are the comments already on the pending review.
25
+ * - `starting`, `threading`, `submitting`: a step was sent; a reload while it was out reads as unknown.
26
+ * - `start-unknown`, `submit-unknown`: whether GitHub applied the step is unknown; check before anything else.
27
+ * - `pending`: the pending review has all its threads, and the last step was refused; send again or discard.
28
+ * - `left`: a pending review could not be discarded; discard it again, or on GitHub.
29
+ */
30
+ export interface ReviewSubmission {
31
+ id: string;
32
+ commitOid: string;
33
+ route: 'threads' | 'body';
34
+ state: 'starting' | 'threading' | 'submitting' | 'start-unknown' | 'submit-unknown' | 'pending' | 'left';
35
+ threadKeys: string[];
36
+ /** The body-only route: a fingerprint of the body that was sent, to recognise the review on GitHub. */
37
+ sent?: string;
38
+ }
39
+
40
+ export interface ReviewDraft {
41
+ event: ReviewEvent;
42
+ body: string;
43
+ comments: Record<string, ReviewComment>;
44
+ /** Project files whose comments go in the review body because GitHub refused a thread on them. */
45
+ inBody: string[];
46
+ /** The pull request head the comments were written for. */
47
+ head?: string;
48
+ submit?: ReviewSubmission;
49
+ }
50
+
51
+ export const emptyReviewDraft = (): ReviewDraft => ({event: 'COMMENT', body: '', comments: {}, inBody: []});
52
+
53
+ const draftKey = (name: string) => `turbowarp-plus-git:review-draft:${name}`;
54
+ const memoryDrafts = new Map<string, ReviewDraft>();
55
+ const draftListeners = new Map<string, Set<() => void>>();
56
+ let persistent = true;
57
+ // Reviews with an action under way; their in-flight steps are not read as interrupted.
58
+ const running = new Set<string>();
59
+
60
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
61
+ const isSha = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{40}$/u.test(value);
62
+ const states: readonly ReviewSubmission['state'][] = ['starting', 'threading', 'submitting', 'start-unknown', 'submit-unknown',
63
+ 'pending', 'left'];
64
+
65
+ /** Keeps only the fields of a draft, so nothing else read from storage reaches a request. */
66
+ function readDraftValue(value: unknown, settle: boolean): ReviewDraft | null {
67
+ if (!isRecord(value)) return null;
68
+ const draft = emptyReviewDraft();
69
+ if (reviewEvents.includes(value.event as ReviewEvent)) draft.event = value.event as ReviewEvent;
70
+ if (typeof value.body === 'string') draft.body = value.body;
71
+ if (isRecord(value.comments)) {
72
+ for (const [key, comment] of Object.entries(value.comments)) {
73
+ if (isRecord(comment) && typeof comment.file === 'string' && typeof comment.heading === 'string' &&
74
+ typeof comment.body === 'string') draft.comments[key] = {file: comment.file, heading: comment.heading, body: comment.body};
75
+ }
76
+ }
77
+ if (Array.isArray(value.inBody)) draft.inBody = value.inBody.filter((file): file is string => typeof file === 'string');
78
+ if (isSha(value.head)) draft.head = value.head;
79
+ const submit = value.submit;
80
+ if (isRecord(submit) && typeof submit.id === 'string' && isSha(submit.commitOid) &&
81
+ (submit.route === 'threads' || submit.route === 'body') && states.includes(submit.state as ReviewSubmission['state']) &&
82
+ Array.isArray(submit.threadKeys)) {
83
+ // A step that was out when the page closed may have reached GitHub.
84
+ const state = submit.state as ReviewSubmission['state'];
85
+ draft.submit = {id: submit.id, commitOid: submit.commitOid, route: submit.route,
86
+ state: !settle ? state : state === 'starting' ? 'start-unknown' : state === 'threading' ? 'left' : state === 'submitting' ? 'submit-unknown' : state,
87
+ threadKeys: submit.threadKeys.filter((key): key is string => typeof key === 'string'),
88
+ ...(typeof submit.sent === 'string' && {sent: submit.sent})};
89
+ }
90
+ return draft;
91
+ }
92
+
93
+ /** The saved draft of a review, such as `owner/repo#42`, or an empty one. */
94
+ export function readReviewDraft(name: string): ReviewDraft {
95
+ const memory = memoryDrafts.get(name);
96
+ if (memory) return memory;
97
+ try {
98
+ const stored = globalThis.localStorage?.getItem(draftKey(name));
99
+ return (stored && readDraftValue(JSON.parse(stored), !running.has(name))) || emptyReviewDraft();
100
+ } catch {
101
+ return emptyReviewDraft();
102
+ }
103
+ }
104
+
105
+ const isEmpty = (draft: ReviewDraft) => !draft.body && !draft.submit && !draft.inBody.length &&
106
+ Object.values(draft.comments).every(comment => !comment.body);
107
+
108
+ /** Saves a draft, or removes it with `null`. Without browser storage, the draft is kept while the page is open. */
109
+ export function writeReviewDraft(name: string, draft: ReviewDraft | null) {
110
+ const value = draft && !isEmpty(draft) ? draft : null;
111
+ try {
112
+ if (!globalThis.localStorage) throw new Error();
113
+ if (value) globalThis.localStorage.setItem(draftKey(name), JSON.stringify(value));
114
+ else globalThis.localStorage.removeItem(draftKey(name));
115
+ memoryDrafts.delete(name);
116
+ persistent = true;
117
+ } catch {
118
+ persistent = false;
119
+ if (value) memoryDrafts.set(name, value);
120
+ else memoryDrafts.delete(name);
121
+ }
122
+ draftListeners.get(name)?.forEach(listener => listener());
123
+ }
124
+
125
+ /** Whether the last write reached browser storage. */
126
+ export const reviewDraftPersistent = () => persistent;
127
+
128
+ export function subscribeReviewDraft(name: string, listener: () => void) {
129
+ const listeners = draftListeners.get(name) ?? new Set();
130
+ draftListeners.set(name, listeners.add(listener));
131
+ return () => {
132
+ listeners.delete(listener);
133
+ if (!listeners.size) draftListeners.delete(name);
134
+ };
135
+ }
136
+
137
+ export const MAX_REVIEW_THREADS = 50;
138
+ export const MAX_REVIEW_BODY = 65536;
139
+
140
+ export interface ReviewPlan {
141
+ /** File-level threads, in the order they are sent. */
142
+ threads: {key: string; path: string; body: string}[];
143
+ /** Script comments that go in the review body. */
144
+ inBody: {key: string; comment: ReviewComment}[];
145
+ /** The review body: the overall comment, then the script comments that are not threads. */
146
+ body: string;
147
+ }
148
+
149
+ const threadBody = (comment: ReviewComment) => `**${comment.heading}**\n\n${comment.body.trim()}`;
150
+
151
+ /** Puts the overall comment first, then each script comment that is not a thread under its file and heading. */
152
+ export function reviewBody(overall: string, comments: readonly {comment: ReviewComment}[]) {
153
+ const parts = overall.trim() ? [overall.trim()] : [];
154
+ if (comments.length) {
155
+ if (parts.length) parts.push('---');
156
+ for (const {comment} of comments) parts.push(`**${comment.file} · ${comment.heading}**\n\n${comment.body.trim()}`);
157
+ }
158
+ return parts.join('\n\n');
159
+ }
160
+
161
+ /**
162
+ * Decides where each script comment goes. A comment becomes a thread when threads are used, its project.source.json is
163
+ * among the pull request's changed files, GitHub has not refused a thread on that file, and fewer than 50 threads come
164
+ * before it. Comments in `exclude` are already on the pending review.
165
+ */
166
+ export function planReview(draft: ReviewDraft, {threads, changedFiles, exclude = []}: {
167
+ threads: boolean; changedFiles: readonly string[]; exclude?: readonly string[];
168
+ }): ReviewPlan {
169
+ const changed = new Set(changedFiles);
170
+ const plan: ReviewPlan = {threads: [], inBody: [], body: ''};
171
+ const comments = Object.entries(draft.comments).filter(([key, comment]) => comment.body.trim() && !exclude.includes(key))
172
+ .sort(([a, x], [b, y]) => x.file < y.file ? -1 : x.file > y.file ? 1 : a < b ? -1 : a > b ? 1 : 0);
173
+ for (const [key, comment] of comments) {
174
+ if (threads && plan.threads.length < MAX_REVIEW_THREADS && changed.has(comment.file) && isReviewThreadPath(comment.file) &&
175
+ !draft.inBody.includes(comment.file)) plan.threads.push({key, path: comment.file, body: threadBody(comment)});
176
+ else plan.inBody.push({key, comment});
177
+ }
178
+ plan.body = reviewBody(draft.body, plan.inBody);
179
+ return plan;
180
+ }
181
+
182
+ // A short fingerprint of a sent body, to find the review on GitHub without keeping a second copy of the words.
183
+ export const bodyFingerprint = (body: string) => {
184
+ let hash = 0x811c9dc5;
185
+ for (let index = 0; index < body.length; index++) hash = Math.imul(hash ^ body.charCodeAt(index), 0x01000193);
186
+ return body.length.toString(36) + '.' + (hash >>> 0).toString(36);
187
+ };
188
+
189
+ export type ReviewProblem = GitHubReviewErrorCode | 'EMPTY_REVIEW' | 'TOO_LONG' | 'UNSUPPORTED' | 'BUSY' |
190
+ 'REVIEW_OUTCOME_UNKNOWN' | 'PENDING_REVIEW_LEFT' | 'NOT_ALLOWED';
191
+
192
+ /** What GitHub would refuse before anything is sent. */
193
+ export function reviewProblem(event: ReviewEvent, plan: ReviewPlan, route: 'threads' | 'body'): ReviewProblem | null {
194
+ const threads = route === 'threads' ? plan.threads.length : 0;
195
+ if (plan.body.length > MAX_REVIEW_BODY || plan.threads.some(thread => thread.body.length > MAX_REVIEW_BODY)) return 'TOO_LONG';
196
+ if (event === 'REQUEST_CHANGES' && !plan.body) return 'EMPTY_REVIEW';
197
+ if (event === 'COMMENT' && !plan.body && !threads) return 'EMPTY_REVIEW';
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * The result of an action. `outcome` says what is now on GitHub:
203
+ * - `submitted`: the review is published (the draft is removed);
204
+ * - `not-sent`: nothing is on GitHub (the draft is kept);
205
+ * - `pending`: a pending review with every thread waits for the last step; send again or discard;
206
+ * - `unknown`: whether a step reached GitHub is unknown; check it;
207
+ * - `left`: a pending review could not be discarded;
208
+ * - `closed`: the check found no review: it was not published, or was discarded on GitHub; send again;
209
+ * - `lost`: the proxy no longer knows the submission (the session ended); look on GitHub.
210
+ */
211
+ export interface ReviewResult {
212
+ outcome: 'submitted' | 'not-sent' | 'pending' | 'unknown' | 'left' | 'closed' | 'lost';
213
+ code?: ReviewProblem;
214
+ /** Seconds to wait before the same action, after a rate limit. */
215
+ retryAfter?: number;
216
+ url?: string;
217
+ /** For `lost`: the person's reviews on the compared commit that GitHub lists, newest first. */
218
+ reviews?: {state: string; submittedAt: string | null}[];
219
+ }
220
+
221
+ export interface ReviewTarget {
222
+ api: GitHubReviewApi;
223
+ /** Where the draft is kept, such as `owner/repo#42`. */
224
+ name: string;
225
+ number: number;
226
+ /** The compared head commit, fetched over Git. */
227
+ head: string;
228
+ /** The pull request's changed files. */
229
+ changedFiles: readonly string[];
230
+ signal?: AbortSignal;
231
+ }
232
+
233
+ /** Runs one action at a time per review, so the steps of a submission are never sent side by side. */
234
+ async function exclusive(target: ReviewTarget, action: () => Promise<ReviewResult>): Promise<ReviewResult> {
235
+ if (running.has(target.name)) return {outcome: 'not-sent', code: 'BUSY'};
236
+ running.add(target.name);
237
+ try {
238
+ return await action();
239
+ } finally {
240
+ running.delete(target.name);
241
+ }
242
+ }
243
+
244
+ const failure = (error: unknown): {code: ReviewProblem; retryAfter?: number} => error instanceof GitHubReviewError ?
245
+ {code: error.code, ...(error.retryAfter !== undefined && {retryAfter: error.retryAfter})} : {code: 'NETWORK_ERROR'};
246
+ const isUnknown = (error: unknown) => error instanceof GitHubReviewError ? error.unknown : true;
247
+ const codeOf = (error: unknown) => error instanceof GitHubReviewError ? error.code : undefined;
248
+
249
+ function update(name: string, change: (draft: ReviewDraft) => ReviewDraft | null) {
250
+ writeReviewDraft(name, change(readReviewDraft(name)));
251
+ }
252
+ const setSubmit = (name: string, submit: ReviewSubmission | undefined) => update(name, draft => ({...draft, submit}));
253
+ const finish = (name: string) => writeReviewDraft(name, null);
254
+
255
+ /** The route the next submission takes, or `null` when the proxy offers neither. */
256
+ export function reviewRoute(api: GitHubReviewApi): 'threads' | 'body' | null {
257
+ return api.available.threads !== false ? 'threads' : api.available.body !== false ? 'body' : null;
258
+ }
259
+
260
+ /**
261
+ * Asks the proxy which review routes it offers, with one read: the review context, then, when the GraphQL routes are
262
+ * off, the list of reviews. A failure other than an unavailable route leaves the route as it was.
263
+ */
264
+ export async function probeReviewRoutes(api: GitHubReviewApi, number: number, signal?: AbortSignal) {
265
+ let context = null;
266
+ try {
267
+ context = await api.context(number, undefined, signal);
268
+ } catch (error) {
269
+ if (codeOf(error) !== 'GITHUB_API_UNAVAILABLE') return {context: null, error};
270
+ }
271
+ if (api.available.threads === false && api.available.body === undefined) {
272
+ try {
273
+ await api.reviews(number, 1, signal);
274
+ } catch (error) {
275
+ if (codeOf(error) !== 'GITHUB_API_UNAVAILABLE') return {context, error};
276
+ }
277
+ }
278
+ return {context};
279
+ }
280
+
281
+ /** Discards the pending review after a failed step. The draft keeps the words either way. */
282
+ async function discardAfter(target: ReviewTarget, submit: ReviewSubmission, cause: unknown): Promise<ReviewResult> {
283
+ const {api, name, number, signal} = target;
284
+ try {
285
+ await api.discard(number, submit.id, signal);
286
+ } catch (error) {
287
+ setSubmit(name, {...submit, state: 'left'});
288
+ return {outcome: 'left', code: 'PENDING_REVIEW_LEFT', ...(failure(error).retryAfter !== undefined && {retryAfter: failure(error).retryAfter})};
289
+ }
290
+ setSubmit(name, undefined);
291
+ return {outcome: 'not-sent', ...failure(cause)};
292
+ }
293
+
294
+ async function lastStep(target: ReviewTarget, submit: ReviewSubmission, draft: ReviewDraft): Promise<ReviewResult> {
295
+ const {api, name, number, signal} = target;
296
+ const plan = planReview(draft, {threads: false, changedFiles: [], exclude: submit.threadKeys});
297
+ const problem = reviewProblem(draft.event, {...plan, threads: submit.threadKeys.map(key => ({key, path: '', body: ''}))}, 'threads');
298
+ if (problem) {
299
+ setSubmit(name, {...submit, state: 'pending'});
300
+ return {outcome: 'pending', code: problem};
301
+ }
302
+ setSubmit(name, {...submit, state: 'submitting'});
303
+ try {
304
+ const published = await api.submit(number, submit.id, submit.threadKeys.length, draft.event, plan.body, signal);
305
+ finish(name);
306
+ return {outcome: 'submitted', url: published.url};
307
+ } catch (error) {
308
+ if (isUnknown(error)) {
309
+ setSubmit(name, {...submit, state: 'submit-unknown'});
310
+ return {outcome: 'unknown', code: 'REVIEW_OUTCOME_UNKNOWN'};
311
+ }
312
+ if (codeOf(error) === 'REVIEW_STEP_CONFLICT') {
313
+ // The proxy closed the submission: the pending review is gone.
314
+ setSubmit(name, undefined);
315
+ return {outcome: 'not-sent', ...failure(error)};
316
+ }
317
+ setSubmit(name, {...submit, state: 'pending'});
318
+ return {outcome: 'pending', ...failure(error)};
319
+ }
320
+ }
321
+
322
+ async function sendThreads(target: ReviewTarget, draft: ReviewDraft): Promise<ReviewResult> {
323
+ const {api, name, number, head, changedFiles, signal} = target;
324
+ const plan = planReview(draft, {threads: true, changedFiles});
325
+ const problem = reviewProblem(draft.event, plan, 'threads');
326
+ if (problem) return {outcome: 'not-sent', code: problem};
327
+ let submit: ReviewSubmission = {id: newReviewSubmitId(), commitOid: head, route: 'threads', state: 'starting', threadKeys: []};
328
+ setSubmit(name, submit);
329
+ try {
330
+ await api.start(number, submit.id, head, signal);
331
+ } catch (error) {
332
+ if (!isUnknown(error)) {
333
+ setSubmit(name, undefined);
334
+ return {outcome: 'not-sent', ...failure(error)};
335
+ }
336
+ submit = {...submit, state: 'start-unknown'};
337
+ setSubmit(name, submit);
338
+ return resolveStart(target, submit, error);
339
+ }
340
+ submit = {...submit, state: 'pending'};
341
+ setSubmit(name, submit);
342
+ for (const [index, thread] of plan.threads.entries()) {
343
+ submit = {...submit, state: 'threading'};
344
+ setSubmit(name, submit);
345
+ try {
346
+ await api.thread(number, submit.id, index, thread.path, thread.body, signal);
347
+ } catch (error) {
348
+ if (codeOf(error) === 'REVIEW_THREAD_REJECTED')
349
+ update(name, current => ({...current, inBody: [...new Set([...current.inBody, thread.path])]}));
350
+ if (codeOf(error) === 'REVIEW_STEP_CONFLICT') {
351
+ // The proxy closed the submission because the pending review is gone.
352
+ setSubmit(name, undefined);
353
+ return {outcome: 'not-sent', ...failure(error)};
354
+ }
355
+ return discardAfter(target, submit, error);
356
+ }
357
+ submit = {...submit, state: 'pending', threadKeys: [...submit.threadKeys, thread.key]};
358
+ setSubmit(name, submit);
359
+ }
360
+ submit = {...submit, state: 'pending'};
361
+ return lastStep(target, submit, readReviewDraft(name));
362
+ }
363
+
364
+ /** After an unknown first step: a pending review that the step created is discarded. */
365
+ async function resolveStart(target: ReviewTarget, submit: ReviewSubmission, cause: unknown): Promise<ReviewResult> {
366
+ const {api, name, number, signal} = target;
367
+ let state: string | undefined;
368
+ try {
369
+ state = (await api.context(number, submit.id, signal)).submit?.state;
370
+ } catch (error) {
371
+ if (codeOf(error) === 'REVIEW_STEP_CONFLICT') return lost(target, submit);
372
+ return {outcome: 'unknown', code: 'REVIEW_OUTCOME_UNKNOWN'};
373
+ }
374
+ if (state === 'closed') {
375
+ setSubmit(name, undefined);
376
+ return {outcome: 'not-sent', ...failure(cause)};
377
+ }
378
+ if (state === 'pending' || state === 'broken') return discardAfter(target, {...submit, state: 'pending'}, cause);
379
+ return {outcome: 'unknown', code: 'REVIEW_OUTCOME_UNKNOWN'};
380
+ }
381
+
382
+ async function lost(target: ReviewTarget, submit: ReviewSubmission): Promise<ReviewResult> {
383
+ let reviews: ReviewResult['reviews'] = [];
384
+ try {
385
+ reviews = (await target.api.context(target.number, undefined, target.signal)).reviews
386
+ .filter(review => review.commitOid === submit.commitOid).map(({state, submittedAt}) => ({state, submittedAt}));
387
+ } catch {
388
+ // The person can still look on GitHub.
389
+ }
390
+ return {outcome: 'lost', code: 'REVIEW_STEP_CONFLICT', reviews};
391
+ }
392
+
393
+ async function sendBody(target: ReviewTarget, draft: ReviewDraft): Promise<ReviewResult> {
394
+ const {api, name, number, head, signal} = target;
395
+ const plan = planReview(draft, {threads: false, changedFiles: []});
396
+ const problem = reviewProblem(draft.event, plan, 'body');
397
+ if (problem) return {outcome: 'not-sent', code: problem};
398
+ const submit: ReviewSubmission = {id: newReviewSubmitId(), commitOid: head, route: 'body', state: 'submitting', threadKeys: [],
399
+ sent: bodyFingerprint(plan.body)};
400
+ setSubmit(name, submit);
401
+ try {
402
+ const created = await api.create(number, submit.id, head, draft.event, plan.body, signal);
403
+ finish(name);
404
+ return {outcome: 'submitted', url: created.url};
405
+ } catch (error) {
406
+ if (isUnknown(error)) {
407
+ setSubmit(name, {...submit, state: 'submit-unknown'});
408
+ return {outcome: 'unknown', code: 'REVIEW_OUTCOME_UNKNOWN'};
409
+ }
410
+ setSubmit(name, undefined);
411
+ return {outcome: 'not-sent', ...failure(error)};
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Sends the draft, or, after the last step was refused (`pending`), sends that step again with the current event and
417
+ * overall comment. A submission whose outcome is unknown, or whose pending review was left, is not sent again.
418
+ */
419
+ export function sendReview(target: ReviewTarget): Promise<ReviewResult> {
420
+ return exclusive(target, async () => {
421
+ const draft = readReviewDraft(target.name);
422
+ const submit = draft.submit;
423
+ if (submit?.state === 'pending' && submit.route === 'threads') return lastStep(target, submit, draft);
424
+ if (submit?.state === 'left') return {outcome: 'left', code: 'PENDING_REVIEW_LEFT'};
425
+ if (submit) return {outcome: 'unknown', code: 'REVIEW_OUTCOME_UNKNOWN'};
426
+ const route = reviewRoute(target.api);
427
+ if (!route) return {outcome: 'not-sent', code: 'UNSUPPORTED'};
428
+ const result = await (route === 'threads' ? sendThreads(target, draft) : sendBody(target, draft));
429
+ // The GraphQL routes are off: nothing was sent, and the next send puts every comment in the body.
430
+ if (result.code === 'GITHUB_API_UNAVAILABLE' && !reviewRoute(target.api)) return {...result, code: 'UNSUPPORTED'};
431
+ return result;
432
+ });
433
+ }
434
+
435
+ /** Finds out what happened to a submission whose outcome is unknown. Reading is safe to repeat. */
436
+ export function checkReview(target: ReviewTarget): Promise<ReviewResult> {
437
+ return exclusive(target, async () => {
438
+ const {api, name, number, signal} = target;
439
+ const submit = readReviewDraft(name).submit;
440
+ if (!submit) return {outcome: 'not-sent'};
441
+ if (submit.route === 'body') {
442
+ const login = api.account();
443
+ try {
444
+ for (let page = 1; page <= 10; page++) {
445
+ const reviews = await api.reviews(number, page, signal);
446
+ if (reviews.some(review => review.login === login && review.commitId === submit.commitOid &&
447
+ bodyFingerprint(review.body) === submit.sent)) {
448
+ finish(name);
449
+ return {outcome: 'submitted'};
450
+ }
451
+ if (reviews.length < 30) break;
452
+ }
453
+ } catch (error) {
454
+ return {outcome: 'unknown', ...failure(error)};
455
+ }
456
+ setSubmit(name, undefined);
457
+ return {outcome: 'closed'};
458
+ }
459
+ if (submit.state === 'start-unknown') return resolveStart(target, submit, new GitHubReviewError('UPSTREAM_FAILURE'));
460
+ let state: string | undefined;
461
+ try {
462
+ state = (await api.context(number, submit.id, signal)).submit?.state;
463
+ } catch (error) {
464
+ if (codeOf(error) === 'REVIEW_STEP_CONFLICT') return lost(target, submit);
465
+ return {outcome: 'unknown', ...failure(error)};
466
+ }
467
+ if (state === 'submitted') {
468
+ finish(name);
469
+ return {outcome: 'submitted'};
470
+ }
471
+ if (state === 'closed') {
472
+ setSubmit(name, undefined);
473
+ return {outcome: 'closed'};
474
+ }
475
+ if (state === 'pending') {
476
+ setSubmit(name, {...submit, state: 'pending'});
477
+ return {outcome: 'pending'};
478
+ }
479
+ if (state === 'broken') {
480
+ setSubmit(name, {...submit, state: 'left'});
481
+ return {outcome: 'left', code: 'PENDING_REVIEW_LEFT'};
482
+ }
483
+ return {outcome: 'unknown', code: 'REVIEW_OUTCOME_UNKNOWN'};
484
+ });
485
+ }
486
+
487
+ /** Discards the submission's pending review on GitHub, when one was left or the last step was refused. */
488
+ export function discardReview(target: ReviewTarget): Promise<ReviewResult> {
489
+ return exclusive(target, async () => {
490
+ const {api, name, number, signal} = target;
491
+ const submit = readReviewDraft(name).submit;
492
+ if (!submit || submit.route !== 'threads' || (submit.state !== 'pending' && submit.state !== 'left'))
493
+ return {outcome: 'not-sent', code: 'NOT_ALLOWED'};
494
+ try {
495
+ await api.discard(number, submit.id, signal);
496
+ } catch (error) {
497
+ // The proxy answers a repeated discard of a closed submission as a duplicate: read what it recorded.
498
+ if (codeOf(error) === 'REVIEW_DUPLICATE' || codeOf(error) === 'REVIEW_STEP_CONFLICT') {
499
+ try {
500
+ if ((await api.context(number, submit.id, signal)).submit?.state === 'closed') {
501
+ setSubmit(name, undefined);
502
+ return {outcome: 'not-sent'};
503
+ }
504
+ } catch (check) {
505
+ if (codeOf(check) === 'REVIEW_STEP_CONFLICT') return lost(target, submit);
506
+ }
507
+ }
508
+ setSubmit(name, {...submit, state: 'left'});
509
+ return {outcome: 'left', code: 'PENDING_REVIEW_LEFT', ...(failure(error).retryAfter !== undefined && {retryAfter: failure(error).retryAfter})};
510
+ }
511
+ setSubmit(name, undefined);
512
+ return {outcome: 'not-sent'};
513
+ });
514
+ }
515
+
516
+ /** Forgets a submission the proxy no longer knows, keeping the words. Anything left on GitHub is handled there. */
517
+ export function forgetReviewSubmission(name: string) {
518
+ setSubmit(name, undefined);
519
+ }
package/src/smart-http.ts CHANGED
@@ -43,7 +43,7 @@ export class GitSmartHttpError extends Error {
43
43
  }
44
44
  }
45
45
 
46
- function remoteUrl(remote: string) {
46
+ export function remoteUrl(remote: string) {
47
47
  // Validate the raw URL before a URL parser can discard dot segments, whitespace, or a default port.
48
48
  const match = /^https:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/u.exec(remote);
49
49
  if (!match || match[0] !== remote || ['.', '..', '.git'].includes(match[2].toLowerCase())) {
@@ -52,7 +52,7 @@ function remoteUrl(remote: string) {
52
52
  return `https://github.com/${match[1]}/${match[2]}.git`;
53
53
  }
54
54
 
55
- function checkConnection({mode, proxyOrigin}: GitProxyConnection) {
55
+ export function checkConnection({mode, proxyOrigin}: GitProxyConnection) {
56
56
  const url = URL.parse(proxyOrigin);
57
57
  if (!url || url.origin !== proxyOrigin ||
58
58
  (mode === 'desktop' ? !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/u.test(proxyOrigin) :
package/src/style.css CHANGED
@@ -92,3 +92,44 @@
92
92
  .twgit-graph-status { margin: 4px 0; font-size: 11px; min-height: 1em; }
93
93
  .twgit-panel .twgit-graph-popup [role="menuitem"]:focus-visible, .twgit-graph-popup [role="menuitem"]:focus-visible { outline-offset: -2px; }
94
94
  .twgit-graph-form-error { margin: 0; color: #a11919; overflow-wrap: anywhere; }
95
+ /* The branch button sits in the host's menu bar and takes its text color; its menu uses the host's menu colors. */
96
+ .twgit-branch-entry { position: relative; flex: 0 1 auto; align-self: center; display: flex; min-width: 0; font: inherit; }
97
+ .twgit-branch-button { display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 16rem; min-height: 2rem; padding: 0 8px;
98
+ color: inherit; background: transparent; border: 0; border-radius: 4px; font: inherit; white-space: nowrap; cursor: pointer; }
99
+ .twgit-branch-button:hover, .twgit-branch-button[aria-expanded="true"] { background: color-mix(in srgb, currentColor 15%, transparent); }
100
+ .twgit-branch-button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
101
+ .twgit-branch-icon { flex: none; }
102
+ .twgit-branch-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
103
+ .twgit-branch-unborn { flex: none; font-size: 11px; opacity: .85; }
104
+ .twgit-branch-caret { flex: none; font-size: 10px; }
105
+ .twgit-branch-popup { position: absolute; z-index: 510; top: calc(100% + 4px); right: 0; display: grid; gap: 2px; min-width: 220px;
106
+ max-width: min(360px, 90vw); max-height: 70vh; overflow: auto; box-sizing: border-box; padding: 4px;
107
+ color: var(--text-primary, CanvasText); background: var(--ui-white, Canvas); border: 1px solid var(--ui-black-transparent, GrayText);
108
+ border-radius: 6px; box-shadow: 0 8px 24px #0004; font: 14px/1.5 system-ui, sans-serif; white-space: normal; }
109
+ .twgit-branch-popup [role^="menuitem"] { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; color: inherit;
110
+ background: transparent; border: 0; border-radius: 4px; font: inherit; text-align: start; overflow-wrap: anywhere; cursor: pointer; }
111
+ .twgit-branch-popup [role^="menuitem"]:hover, .twgit-branch-popup [role^="menuitem"]:focus-visible {
112
+ background: color-mix(in srgb, currentColor 12%, transparent); }
113
+ .twgit-branch-popup [role^="menuitem"]:focus-visible { outline: 2px solid var(--looks-secondary, Highlight); outline-offset: -2px; }
114
+ .twgit-branch-popup [aria-disabled="true"]:not([aria-checked="true"]) { opacity: .55; cursor: default; }
115
+ .twgit-branch-popup [aria-checked="true"] { font-weight: 600; }
116
+ .twgit-branch-popup [data-separator-before="true"] { margin-top: 3px; box-shadow: 0 -3px 0 -2px color-mix(in srgb, currentColor 25%, transparent); }
117
+ .twgit-branch-check { flex: none; width: 1em; text-align: center; }
118
+ .twgit-branch-popup form { display: grid; gap: 6px; padding: 6px; font-size: 12px; }
119
+ .twgit-branch-popup form label { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
120
+ .twgit-branch-popup form input:not([type]) { flex: 1; min-width: 10em; font: inherit; }
121
+ .twgit-branch-popup form button { color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 4px; padding: 3px 8px; font: inherit; cursor: pointer; }
122
+ .twgit-branch-popup form button:disabled { opacity: .5; cursor: default; }
123
+ /* The branch button's result, at the bottom of the window, in the project folder status's look. */
124
+ .twgit-snackbar { position: fixed; z-index: 1000; bottom: 1rem; left: 50%; transform: translateX(-50%); display: flex; align-items: center;
125
+ gap: 10px; box-sizing: border-box; max-width: min(32rem, calc(100vw - 2rem)); padding: .5rem .5rem .5rem .85rem; background: Canvas;
126
+ color: CanvasText; border: 1px solid currentColor; border-radius: .4rem; box-shadow: 0 4px 16px #0003; font: 14px/1.5 system-ui, sans-serif; }
127
+ .twgit-snackbar > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
128
+ .twgit-snackbar button { flex: none; color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 4px;
129
+ padding: 2px 8px; font: inherit; cursor: pointer; }
130
+ .twgit-snackbar button.twgit-close { border-color: transparent; padding: 0; }
131
+ .twgit-snackbar button:focus-visible { outline: 2px solid currentColor; outline-offset: 1px; }
132
+ /* The history in a side panel of the host. */
133
+ .twgit-history-view { box-sizing: border-box; padding: 8px; font: 13px/1.5 system-ui, sans-serif; }
134
+ .twgit-history-view > p { margin: 0 0 8px; }
135
+ .twgit-history-view > button { color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 4px; padding: 3px 8px; font: inherit; cursor: pointer; }