@kubohiroya/turbowarp-plus-editor-git 0.18.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.
- package/README.ja.md +10 -2
- package/README.md +10 -2
- package/dist/branch-button.d.ts +15 -0
- package/dist/change-marks.d.ts +8 -0
- package/dist/connection-settings.d.ts +3 -1
- package/dist/github-pull-requests.d.ts +2 -2
- package/dist/github-review-api.d.ts +112 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +377 -268
- package/dist/messages.d.ts +101 -0
- package/dist/{project-asset-paths-BzwVf9HE.js → project-asset-paths-DNaoWs3E.js} +1 -1
- package/dist/{project-comparison-BXOQcZ73.js → project-comparison-B_3QZ6M-.js} +1414 -33
- package/dist/project-source-diff.d.ts +5 -0
- package/dist/project-source-diff.js +4 -4
- package/dist/project-source-review.d.ts +6 -1
- package/dist/pull-request-panel.d.ts +2 -0
- package/dist/remote-operations.d.ts +1 -1
- package/dist/{repository-ByKRBftJ.js → repository-DXRZEtYk.js} +2 -3
- package/dist/repository.js +1 -1
- package/dist/review-composer.d.ts +23 -0
- package/dist/review-submit.d.ts +144 -0
- package/dist/{smart-http-BmHEkE7K.js → smart-http-eARzlXHO.js} +101 -2
- package/dist/smart-http.d.ts +2 -0
- package/dist/smart-http.js +1 -1
- package/dist/style.css +23 -0
- package/package.json +1 -1
- package/src/branch-button.tsx +79 -6
- package/src/change-marks.ts +22 -0
- package/src/connection-settings.tsx +9 -2
- package/src/github-review-api.ts +298 -0
- package/src/index.tsx +153 -15
- package/src/messages.ts +76 -2
- package/src/project-source-diff.css +10 -0
- package/src/project-source-diff.tsx +6 -0
- package/src/project-source-review.tsx +33 -5
- package/src/pull-request-panel.tsx +27 -6
- package/src/remote-operations.ts +1 -1
- package/src/review-composer.tsx +166 -0
- package/src/review-submit.ts +519 -0
- package/src/smart-http.ts +2 -2
- package/src/style.css +13 -0
- package/dist/objectSpread2-Dkd68nJH.js +0 -101
|
@@ -120,3 +120,13 @@
|
|
|
120
120
|
.twgit-review-target-status { font-weight: 600; }
|
|
121
121
|
.twgit-review-asset { display: flex; align-items: center; gap: 8px; }
|
|
122
122
|
.twgit-review-asset img { width: 32px; height: 32px; object-fit: contain; }
|
|
123
|
+
.twgit-review-field { display: grid; gap: 4px; }
|
|
124
|
+
.twgit-review-field textarea { font: inherit; resize: vertical; min-height: 3em; }
|
|
125
|
+
.twgit-review-comment { padding: 0 12px 10px; }
|
|
126
|
+
.twgit-review-composer { display: grid; gap: 10px; margin-top: 14px; padding: 12px 14px; border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas)); border-radius: 8px; }
|
|
127
|
+
.twgit-review-composer h3 { margin: 0; }
|
|
128
|
+
.twgit-review-events { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0; padding: 6px 10px; border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas)); border-radius: 6px; }
|
|
129
|
+
.twgit-review-summary, .twgit-review-note, .twgit-review-state { margin: 0; }
|
|
130
|
+
.twgit-review-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
131
|
+
.twgit-review-result { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin: 0; }
|
|
132
|
+
.twgit-review-result p { margin: 0; }
|
|
@@ -8,6 +8,12 @@ import type {LocationStatus} from './block-diff-preview.js';
|
|
|
8
8
|
import './project-source-diff.css';
|
|
9
9
|
|
|
10
10
|
export {createGitHubPullRequests, gitHubRepositoryFromOrigin} from './github-pull-requests.js';
|
|
11
|
+
export {createGitHubReviewApi, GitHubReviewError, isReviewThreadPath} from './github-review-api.js';
|
|
12
|
+
export type {GitHubReviewApi, GitHubReviewApiOptions, GitHubReviewErrorCode, ReviewContext, ReviewEvent} from './github-review-api.js';
|
|
13
|
+
export {checkReview, discardReview, emptyReviewDraft, forgetReviewSubmission, planReview, probeReviewRoutes, readReviewDraft,
|
|
14
|
+
reviewBody, reviewProblem, reviewRoute, sendReview, subscribeReviewDraft, writeReviewDraft} from './review-submit.js';
|
|
15
|
+
export {PullRequestReviewComposer} from './review-composer.js';
|
|
16
|
+
export type {ReviewComment, ReviewDraft, ReviewPlan, ReviewProblem, ReviewResult, ReviewSubmission, ReviewTarget} from './review-submit.js';
|
|
11
17
|
export {ProjectSourceComparison, ProjectSourceReview} from './project-source-review.js';
|
|
12
18
|
export {ProjectComparisonDialog, openProjectComparison} from './project-comparison.js';
|
|
13
19
|
export {openProjectSourceTabs} from './project-source-tabs.js';
|
|
@@ -11,6 +11,8 @@ import {currentCostume, useAssetImage, useCostumeImage} from './project-assets.j
|
|
|
11
11
|
import {assetFileName, projectPropertyLabel} from './project-change-model.js';
|
|
12
12
|
import {hatName, legend, summaryPhrase} from './review-words.js';
|
|
13
13
|
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
14
|
+
import {changeReviewDraft, useReviewDraft} from './review-composer.js';
|
|
15
|
+
import {reviewLabelsFor} from './messages.js';
|
|
14
16
|
|
|
15
17
|
type Comparison = ReturnType<typeof compareProjectForReview>;
|
|
16
18
|
|
|
@@ -35,6 +37,11 @@ export interface ProjectSourceReviewProps {
|
|
|
35
37
|
* the content of each script, so a mark stays until that script changes. Without it, marks last while the review is open.
|
|
36
38
|
*/
|
|
37
39
|
rememberAs?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Offers a comment field under each code change, kept in the review draft of this name (such as `owner/repo#42`) and
|
|
42
|
+
* sent with the review.
|
|
43
|
+
*/
|
|
44
|
+
commentAs?: string;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
// Like the Editor's Code tab, a connected group of blocks is called code, and each item is named by its hat block.
|
|
@@ -131,8 +138,12 @@ function scriptReviewTitle(script: ScriptReview, labels: typeof ja) {
|
|
|
131
138
|
return beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
|
|
132
139
|
}
|
|
133
140
|
|
|
134
|
-
|
|
141
|
+
type ScriptCommentField = {value: string; set(value: string, heading: string): void};
|
|
142
|
+
|
|
143
|
+
function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location, expanded = false, comment, target}: {
|
|
135
144
|
script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
|
|
145
|
+
/** The person's comment on this code, sent with the review. */
|
|
146
|
+
comment?: ScriptCommentField; target?: TargetReview;
|
|
136
147
|
renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; location?: BlockLocation;
|
|
137
148
|
/** Starts open, as in a tab that holds this one script. */
|
|
138
149
|
expanded?: boolean;
|
|
@@ -151,6 +162,7 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
|
|
|
151
162
|
{status === 'reorganized' && <small>{script.before.length} → {script.after.length}</small>}
|
|
152
163
|
{summary && <small className="twgit-review-summary">{summary}</small>}
|
|
153
164
|
{reviewed && <small>{labels.reviewed}</small>}
|
|
165
|
+
{comment?.value && <small data-git-review-commented="">{reviewLabelsFor(locale).commented}</small>}
|
|
154
166
|
</summary>
|
|
155
167
|
{open && <>
|
|
156
168
|
<div className="twgit-block-diff-sides">
|
|
@@ -167,6 +179,9 @@ function ScriptComparison({script, labels, reviewed = false, setReviewed, render
|
|
|
167
179
|
{toggle}
|
|
168
180
|
{setReviewed && <label className="twgit-review-checked"><input type="checkbox" checked={reviewed}
|
|
169
181
|
onChange={event => setReviewed(event.currentTarget.checked)}/> {labels.reviewed}</label>}
|
|
182
|
+
{comment && <label className="twgit-review-field twgit-review-comment">{reviewLabelsFor(locale).scriptComment}
|
|
183
|
+
<textarea rows={3} value={comment.value} onChange={event => comment.set(event.currentTarget.value,
|
|
184
|
+
`${target?.isStage ? labels.stage : target?.name ?? ''} · ${title} · ${script.id}`)}/></label>}
|
|
170
185
|
</>}
|
|
171
186
|
</details>;
|
|
172
187
|
}
|
|
@@ -208,7 +223,8 @@ function TargetThumbnails({target, labels, loadAsset}: {target: TargetReview; la
|
|
|
208
223
|
function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBlock, review}: {
|
|
209
224
|
comparison: Comparison; labels: typeof ja; renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader;
|
|
210
225
|
locateBlock?: ProjectBlockLocator;
|
|
211
|
-
review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void
|
|
226
|
+
review?: {file: string; keys: ReadonlySet<string>; set(key: string, value: boolean): void;
|
|
227
|
+
comments?: {get(key: string): string; set(key: string, value: string, heading: string): void}};
|
|
212
228
|
}) {
|
|
213
229
|
const location = useBlockLocation(comparison, locateBlock);
|
|
214
230
|
return <>
|
|
@@ -222,8 +238,10 @@ function ComparisonTargets({comparison, labels, renderBlock, loadAsset, locateBl
|
|
|
222
238
|
{target.status === 'updated' && target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
|
|
223
239
|
{target.scripts.map(script => {
|
|
224
240
|
const key = reviewKey(review?.file, target, script);
|
|
241
|
+
const comments = review?.comments;
|
|
225
242
|
return <ScriptComparison key={key} script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset}
|
|
226
|
-
location={location} reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}
|
|
243
|
+
location={location} reviewed={review?.keys.has(key)} setReviewed={review && (value => review.set(key, value))}
|
|
244
|
+
target={target} comment={comments && {value: comments.get(key), set: (value, heading) => comments.set(key, value, heading)}}/>;
|
|
227
245
|
})}
|
|
228
246
|
</section>)}
|
|
229
247
|
</>;
|
|
@@ -383,8 +401,18 @@ const writeMarks = (name: string, marks: Record<string, string>, remember = true
|
|
|
383
401
|
markListeners.get(name)?.forEach(listener => listener());
|
|
384
402
|
};
|
|
385
403
|
|
|
386
|
-
export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en', rememberAs}: ProjectSourceReviewProps) {
|
|
404
|
+
export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en', rememberAs, commentAs}: ProjectSourceReviewProps) {
|
|
387
405
|
const labels = locale.startsWith('ja') ? ja : en;
|
|
406
|
+
const draft = useReviewDraft(commentAs);
|
|
407
|
+
const commentsFor = (file: string) => commentAs && draft ? {
|
|
408
|
+
get: (key: string) => draft.comments[key]?.body ?? '',
|
|
409
|
+
set: (key: string, body: string, heading: string) => changeReviewDraft(commentAs, current => {
|
|
410
|
+
const comments = {...current.comments};
|
|
411
|
+
if (body) comments[key] = {file, heading, body};
|
|
412
|
+
else delete comments[key];
|
|
413
|
+
return {...current, comments};
|
|
414
|
+
})
|
|
415
|
+
} : undefined;
|
|
388
416
|
const entries = useMemo(() => files.map(file => {
|
|
389
417
|
if (!('patch' in file)) return {file};
|
|
390
418
|
try {
|
|
@@ -457,7 +485,7 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
457
485
|
{comparison && <ComparisonTargets comparison={comparison} labels={labels} renderBlock={renderBlock}
|
|
458
486
|
loadAsset={'loadAsset' in file ? file.loadAsset : undefined}
|
|
459
487
|
locateBlock={'locateBlock' in file ? file.locateBlock : undefined}
|
|
460
|
-
review={{file: file.path, keys: reviewed, set: setReviewed}}/>}
|
|
488
|
+
review={{file: file.path, keys: reviewed, set: setReviewed, comments: commentsFor(file.path)}}/>}
|
|
461
489
|
</section>)}
|
|
462
490
|
</section>;
|
|
463
491
|
}
|
|
@@ -11,6 +11,9 @@ import {GitPanelError} from './filesystem.js';
|
|
|
11
11
|
import type {GetRemoteSession} from './remote-operations.js';
|
|
12
12
|
import type {ProjectSourceReviewFile} from './project-source-review.js';
|
|
13
13
|
import {pullRequestBranch} from './local-operations.js';
|
|
14
|
+
import {createGitHubReviewApi} from './github-review-api.js';
|
|
15
|
+
import {PullRequestReviewComposer} from './review-composer.js';
|
|
16
|
+
import {reviewLabelsFor} from './messages.js';
|
|
14
17
|
|
|
15
18
|
type OpenTabs = (key: string, files: readonly ProjectSourceTabsFile[], rememberAs?: string) => number;
|
|
16
19
|
|
|
@@ -87,6 +90,8 @@ export interface PullRequestOpenLocally {
|
|
|
87
90
|
export interface PullRequestConnection {
|
|
88
91
|
getSession: GetRemoteSession;
|
|
89
92
|
connected(): boolean;
|
|
93
|
+
/** Whether the connection was made with the grant to send reviews; then a review can be sent through it. */
|
|
94
|
+
reviewGranted?(): boolean;
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
type Result<T> = {data: T; error?: never} | {data?: never; error: string};
|
|
@@ -101,12 +106,21 @@ type Pull = {
|
|
|
101
106
|
git?: {branches: readonly string[] | null};
|
|
102
107
|
};
|
|
103
108
|
|
|
104
|
-
function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs, repository, connection, openLocally}: {
|
|
105
|
-
name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
|
|
109
|
+
function PullRequests({name, remote, labels, locale, renderBlock, locateFor, openTabs, repository, connection, openLocally}: {
|
|
110
|
+
name: string; remote?: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
|
|
106
111
|
locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
|
|
107
112
|
openTabs?: OpenTabs; repository: Repository; connection?: PullRequestConnection; openLocally?: PullRequestOpenLocally;
|
|
108
113
|
}) {
|
|
109
114
|
const client = useMemo(() => createGitHubPullRequests(name), [name]);
|
|
115
|
+
// Reviews go through the connection's proxy, which keeps the GitHub token.
|
|
116
|
+
const reviewApi = useMemo(() => {
|
|
117
|
+
if (!remote || !connection) return null;
|
|
118
|
+
try {
|
|
119
|
+
return createGitHubReviewApi({remote, getSession: connection.getSession});
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}, [remote, connection]);
|
|
110
124
|
const [page, setPage] = useState(1);
|
|
111
125
|
const [listing, setListing] = useState<{page: number; result: Result<Listing>}>();
|
|
112
126
|
const [input, setInput] = useState('');
|
|
@@ -173,6 +187,8 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs, r
|
|
|
173
187
|
setInput(String(number));
|
|
174
188
|
};
|
|
175
189
|
const pr = detail?.data;
|
|
190
|
+
// A review is written and sent for contents read over the connection, when it was made with the review grant.
|
|
191
|
+
const reviewing = !!pr?.git && !!reviewApi && !!connection?.connected() && !!connection.reviewGranted?.();
|
|
176
192
|
return <div className="twgit-content">
|
|
177
193
|
<aside>
|
|
178
194
|
<h3>{labels.title}</h3>
|
|
@@ -229,8 +245,13 @@ function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs, r
|
|
|
229
245
|
{labels.openLocally.replace('{branch}', pullRequestBranch(pr.number))}</button>}
|
|
230
246
|
</p>
|
|
231
247
|
{pr.git && <p className="twgit-context" role="status">{labels.viaConnection}{pr.git.branches && <> {labels.noMetadata}</>}</p>}
|
|
248
|
+
{pr.git && connection?.connected() && !connection.reviewGranted?.() &&
|
|
249
|
+
<p className="twgit-context" role="status">{reviewLabelsFor(locale).needsGrant}</p>}
|
|
232
250
|
<ProjectSourceReview title={pr.title} description={pr.description} files={locateFor ? pr.files.map(file => 'patch' in file ? {...file, locateBlock: locateFor(file.path, pr.head.sha)} : file) : pr.files}
|
|
233
|
-
renderBlock={renderBlock} locale={locale} rememberAs={`${name}#${pr.number}`}
|
|
251
|
+
renderBlock={renderBlock} locale={locale} rememberAs={`${name}#${pr.number}`}
|
|
252
|
+
commentAs={reviewing ? `${name}#${pr.number}` : undefined}/>
|
|
253
|
+
{reviewing && reviewApi && <PullRequestReviewComposer key={`${name}#${pr.number}`} api={reviewApi} name={`${name}#${pr.number}`}
|
|
254
|
+
number={pr.number} head={pr.head.sha} changedFiles={pr.files.map(file => file.path)} url={pr.url} locale={locale}/>}
|
|
234
255
|
</>}
|
|
235
256
|
</main>
|
|
236
257
|
</div>;
|
|
@@ -254,11 +275,11 @@ export function PullRequestReviewPanel({repository, locale, renderBlock, locateF
|
|
|
254
275
|
locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
|
|
255
276
|
}) {
|
|
256
277
|
const labels = locale.startsWith('ja') ? ja : en;
|
|
257
|
-
const [origin, setOrigin] = useState<{repository: Repository; name: string | null; failed?: boolean}>();
|
|
278
|
+
const [origin, setOrigin] = useState<{repository: Repository; name: string | null; remote?: string; failed?: boolean}>();
|
|
258
279
|
useEffect(() => {
|
|
259
280
|
let active = true;
|
|
260
281
|
void repository.readOrigin().then(value => {
|
|
261
|
-
if (active) setOrigin({repository, name: gitHubRepositoryFromOrigin(value)});
|
|
282
|
+
if (active) setOrigin({repository, name: gitHubRepositoryFromOrigin(value), remote: value});
|
|
262
283
|
}, () => {
|
|
263
284
|
if (active) setOrigin({repository, name: null, failed: true});
|
|
264
285
|
});
|
|
@@ -266,6 +287,6 @@ export function PullRequestReviewPanel({repository, locale, renderBlock, locateF
|
|
|
266
287
|
}, [repository]);
|
|
267
288
|
if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
|
|
268
289
|
if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
|
|
269
|
-
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}
|
|
290
|
+
return <PullRequests key={origin.name} name={origin.name} remote={origin.remote} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}
|
|
270
291
|
repository={repository} connection={connection} openLocally={openLocally}/>;
|
|
271
292
|
}
|
package/src/remote-operations.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {GitSmartHttpOptions} from './smart-http.js';
|
|
|
6
6
|
|
|
7
7
|
export type RemoteOperation = 'fetch' | 'pull' | 'push';
|
|
8
8
|
export type RemoteSession = NonNullable<Awaited<ReturnType<GitSmartHttpOptions['getSession']>>>;
|
|
9
|
-
export type GetRemoteSession = (remote: string, write: boolean) => Promise<RemoteSession | null>;
|
|
9
|
+
export type GetRemoteSession = (remote: string, write: boolean, review?: boolean) => Promise<RemoteSession | null>;
|
|
10
10
|
|
|
11
11
|
type GitOptions = {fs: ReturnType<typeof createMutationFs>['fs']; dir: string; gitdir: string; cache: object};
|
|
12
12
|
const sessionCodes = ['SESSION_REQUIRED', 'SESSION_UNAVAILABLE', 'SESSION_EXPIRED', 'REAUTH_REQUIRED', 'INVALID_SESSION'];
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React, {useEffect, useRef, useState} from 'react';
|
|
2
|
+
import {CloseButton} from './close-button.js';
|
|
3
|
+
import {reviewEvents} from './github-review-api.js';
|
|
4
|
+
import type {GitHubReviewApi} from './github-review-api.js';
|
|
5
|
+
import {reviewLabelsFor} from './messages.js';
|
|
6
|
+
import type {ReviewLabels} from './messages.js';
|
|
7
|
+
import {
|
|
8
|
+
checkReview, discardReview, forgetReviewSubmission, planReview, probeReviewRoutes, readReviewDraft, reviewDraftPersistent,
|
|
9
|
+
reviewProblem, reviewRoute, sendReview, subscribeReviewDraft, writeReviewDraft
|
|
10
|
+
} from './review-submit.js';
|
|
11
|
+
import type {ReviewDraft, ReviewResult} from './review-submit.js';
|
|
12
|
+
|
|
13
|
+
/** The saved draft of a review, updated when any view of the same review writes it. */
|
|
14
|
+
export function useReviewDraft(name: string | undefined): ReviewDraft | null {
|
|
15
|
+
const [, refresh] = useState(0);
|
|
16
|
+
useEffect(() => name ? subscribeReviewDraft(name, () => refresh(count => count + 1)) : undefined, [name]);
|
|
17
|
+
return name ? readReviewDraft(name) : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Changes the saved draft from what is stored now, so other views' words are kept. */
|
|
21
|
+
export function changeReviewDraft(name: string, change: (draft: ReviewDraft) => ReviewDraft) {
|
|
22
|
+
writeReviewDraft(name, change(readReviewDraft(name)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sessionCodes = ['SESSION_REQUIRED', 'SESSION_EXPIRED', 'REAUTH_REQUIRED', 'SESSION_UNAVAILABLE', 'INVALID_SESSION'];
|
|
26
|
+
|
|
27
|
+
function resultMessage(result: ReviewResult, labels: ReviewLabels) {
|
|
28
|
+
const code = result.code;
|
|
29
|
+
if (code === 'RATE_LIMITED') return labels.RATE_LIMITED.replace('{seconds}', String(result.retryAfter ?? 60));
|
|
30
|
+
if (result.outcome === 'submitted') return labels.sent;
|
|
31
|
+
if (result.outcome === 'closed') return labels.closed;
|
|
32
|
+
if (result.outcome === 'lost') return labels.lost.replace('{count}', String(result.reviews?.length ?? 0));
|
|
33
|
+
if (result.outcome === 'left') return labels.PENDING_REVIEW_LEFT;
|
|
34
|
+
if (result.outcome === 'unknown') return labels.REVIEW_OUTCOME_UNKNOWN;
|
|
35
|
+
if (code && sessionCodes.includes(code)) return labels.session;
|
|
36
|
+
if (code === 'PENDING_REVIEW_EXISTS' || code === 'REVIEW_REJECTED' || code === 'REVIEW_THREAD_REJECTED' || code === 'EMPTY_REVIEW' ||
|
|
37
|
+
code === 'TOO_LONG' || code === 'FORBIDDEN' || code === 'GITHUB_API_UNAVAILABLE') {
|
|
38
|
+
const message = labels[code];
|
|
39
|
+
return result.outcome === 'pending' ? labels.pending + ' ' + message : message;
|
|
40
|
+
}
|
|
41
|
+
if (code === 'UNSUPPORTED') return labels.unsupported;
|
|
42
|
+
if (result.outcome === 'pending') return labels.pending;
|
|
43
|
+
if (result.outcome === 'not-sent' && !code) return labels.discarded;
|
|
44
|
+
return labels.failed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The overall comment, the result, how the code comments will be sent, and the send button of a pull request review.
|
|
49
|
+
* The code comments are written under each code change of the review that shares `name`.
|
|
50
|
+
*/
|
|
51
|
+
export function PullRequestReviewComposer({api, name, number, head, changedFiles, url, locale}: {
|
|
52
|
+
api: GitHubReviewApi;
|
|
53
|
+
/** Where the draft is kept, such as `owner/repo#42`. */
|
|
54
|
+
name: string;
|
|
55
|
+
number: number;
|
|
56
|
+
/** The compared head commit. */
|
|
57
|
+
head: string;
|
|
58
|
+
changedFiles: readonly string[];
|
|
59
|
+
/** The pull request on GitHub. */
|
|
60
|
+
url: string;
|
|
61
|
+
locale: string;
|
|
62
|
+
}) {
|
|
63
|
+
const labels = reviewLabelsFor(locale);
|
|
64
|
+
const draft = useReviewDraft(name)!;
|
|
65
|
+
const [route, setRoute] = useState(() => reviewRoute(api));
|
|
66
|
+
const [busy, setBusy] = useState<'send' | 'check' | null>(null);
|
|
67
|
+
const [result, setResult] = useState<ReviewResult | null>(null);
|
|
68
|
+
const [blocked, setBlocked] = useState(false);
|
|
69
|
+
const active = useRef(true);
|
|
70
|
+
useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
|
|
71
|
+
const target = {api, name, number, head, changedFiles};
|
|
72
|
+
|
|
73
|
+
const run = async (kind: 'send' | 'check', action: () => Promise<ReviewResult>) => {
|
|
74
|
+
if (busy || blocked) return;
|
|
75
|
+
setBusy(kind);
|
|
76
|
+
setResult(null);
|
|
77
|
+
let next: ReviewResult;
|
|
78
|
+
try {
|
|
79
|
+
next = await action();
|
|
80
|
+
} catch {
|
|
81
|
+
next = {outcome: 'not-sent', code: 'NETWORK_ERROR'};
|
|
82
|
+
}
|
|
83
|
+
if (!active.current) return;
|
|
84
|
+
setBusy(null);
|
|
85
|
+
setRoute(reviewRoute(api));
|
|
86
|
+
if (next.code === 'BUSY') return;
|
|
87
|
+
setResult(next);
|
|
88
|
+
if (next.retryAfter) {
|
|
89
|
+
setBlocked(true);
|
|
90
|
+
setTimeout(() => {if (active.current) setBlocked(false);}, next.retryAfter * 1000);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Ask once which routes the connection offers, and settle a submission whose outcome was unknown when the page closed.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
void (async () => {
|
|
98
|
+
await probeReviewRoutes(api, number, controller.signal);
|
|
99
|
+
if (controller.signal.aborted) return;
|
|
100
|
+
setRoute(reviewRoute(api));
|
|
101
|
+
const state = readReviewDraft(name).submit?.state;
|
|
102
|
+
if (state === 'start-unknown' || state === 'submit-unknown') await run('check', () => checkReview(target));
|
|
103
|
+
})();
|
|
104
|
+
return () => controller.abort();
|
|
105
|
+
}, [api, name, number]);
|
|
106
|
+
// The comments were written for the head they were first written on.
|
|
107
|
+
const hasWords = !!draft.body || Object.values(draft.comments).some(comment => comment.body);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (hasWords && !draft.head) changeReviewDraft(name, current => ({...current, head}));
|
|
110
|
+
}, [hasWords, draft.head, head, name]);
|
|
111
|
+
|
|
112
|
+
const submit = draft.submit;
|
|
113
|
+
const plan = planReview(draft, {threads: route === 'threads' && submit?.state !== 'pending', changedFiles, exclude: submit?.threadKeys});
|
|
114
|
+
const threads = submit?.state === 'pending' ? submit.threadKeys.length : route === 'threads' ? plan.threads.length : 0;
|
|
115
|
+
const comments = (submit?.state === 'pending' ? submit.threadKeys.length : 0) + plan.threads.length + plan.inBody.length;
|
|
116
|
+
const problem = route && reviewProblem(draft.event, {...plan, threads: Array.from({length: threads}, () => ({key: '', path: '', body: ''}))},
|
|
117
|
+
route);
|
|
118
|
+
const disabled = !!busy || blocked;
|
|
119
|
+
const message = result && resultMessage(result, labels);
|
|
120
|
+
const failed = result && result.outcome !== 'submitted' && !(result.outcome === 'not-sent' && !result.code);
|
|
121
|
+
const pullLink = <a href={url} target="_blank" rel="noreferrer">{labels.openPullRequest}</a>;
|
|
122
|
+
|
|
123
|
+
return <section className="twgit-review-composer" aria-label={labels.composer} data-git-review-composer="">
|
|
124
|
+
<h3>{labels.composer}</h3>
|
|
125
|
+
{!reviewDraftPersistent() && <p role="status" className="twgit-review-note">{labels.notStored}</p>}
|
|
126
|
+
{draft.head && draft.head !== head && <p role="status" className="twgit-review-note">{labels.headChanged}{' '}
|
|
127
|
+
<button type="button" onClick={() => changeReviewDraft(name, current => ({...current, head}))}>{labels.useVersion}</button></p>}
|
|
128
|
+
<label className="twgit-review-field">{labels.overall}
|
|
129
|
+
<textarea value={draft.body} rows={4} onChange={event => {
|
|
130
|
+
const body = event.currentTarget.value;
|
|
131
|
+
changeReviewDraft(name, current => ({...current, body}));
|
|
132
|
+
}}/></label>
|
|
133
|
+
<fieldset className="twgit-review-events">
|
|
134
|
+
<legend>{labels.event}</legend>
|
|
135
|
+
{reviewEvents.map(event => <label key={event}><input type="radio" name={`twgit-review-event-${name}`} value={event}
|
|
136
|
+
checked={draft.event === event} onChange={() => changeReviewDraft(name, current => ({...current, event}))}/> {labels[event]}</label>)}
|
|
137
|
+
</fieldset>
|
|
138
|
+
{route === null ? <p role="status">{labels.unsupported}</p> : <>
|
|
139
|
+
{(!submit || submit.state === 'pending') && <p className="twgit-review-summary" data-git-review-summary="">{!comments ? labels.summaryNone :
|
|
140
|
+
route === 'threads' ? labels.summaryThreads.replace('{threads}', String(threads)).replace('{body}', String(plan.inBody.length)) :
|
|
141
|
+
labels.summaryBody.replace('{body}', String(plan.inBody.length))}</p>}
|
|
142
|
+
<div className="twgit-review-actions">
|
|
143
|
+
{(!submit || submit.state === 'pending') && <button type="button" data-git-review-send="" disabled={disabled || !!problem}
|
|
144
|
+
title={problem ? resultMessage({outcome: 'not-sent', code: problem}, labels) : undefined}
|
|
145
|
+
onClick={() => void run('send', () => sendReview(target))}>{submit ? labels.sendAgain : labels.send}</button>}
|
|
146
|
+
{(submit?.state === 'start-unknown' || submit?.state === 'submit-unknown') &&
|
|
147
|
+
<button type="button" disabled={disabled} onClick={() => void run('check', () => checkReview(target))}>{labels.check}</button>}
|
|
148
|
+
{(submit?.state === 'pending' || submit?.state === 'left') &&
|
|
149
|
+
<button type="button" disabled={disabled} onClick={() => void run('send', () => discardReview(target))}>{labels.discard}</button>}
|
|
150
|
+
{(submit?.state === 'left' || result?.outcome === 'lost') && <>
|
|
151
|
+
{pullLink}
|
|
152
|
+
<button type="button" disabled={disabled} onClick={() => {forgetReviewSubmission(name); setResult(null);}}>{labels.forget}</button>
|
|
153
|
+
</>}
|
|
154
|
+
</div>
|
|
155
|
+
{busy && <p role="status">{busy === 'send' ? labels.sending : labels.checking}</p>}
|
|
156
|
+
{!busy && !result && submit && <p role="status" className="twgit-review-state">
|
|
157
|
+
{submit.state === 'pending' ? labels.pending : submit.state === 'left' ? labels.PENDING_REVIEW_LEFT : labels.REVIEW_OUTCOME_UNKNOWN}</p>}
|
|
158
|
+
{message && result && <div className={failed ? 'twgit-review-result twgit-error' : 'twgit-review-result twgit-context'}
|
|
159
|
+
role={failed ? 'alert' : 'status'}>
|
|
160
|
+
<p>{message}{result.code === 'PENDING_REVIEW_EXISTS' && <> {pullLink}</>}
|
|
161
|
+
{result.outcome === 'submitted' && result.url && <> <a href={result.url} target="_blank" rel="noreferrer">{labels.openReview}</a></>}</p>
|
|
162
|
+
<CloseButton label={labels.dismiss} onClick={() => setResult(null)}/>
|
|
163
|
+
</div>}
|
|
164
|
+
</>}
|
|
165
|
+
</section>;
|
|
166
|
+
}
|