Add very basic support for allowing another extension to provide a review (#6055)
This commit is contained in:
Родитель
496e293e6f
Коммит
4cf2e88cd6
|
@ -4,6 +4,7 @@
|
|||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { IAccount, ILabel, IMilestone, IProject, ITeam, MergeMethod, MergeMethodsAvailability } from '../src/github/interface';
|
||||
import { PreReviewState } from '../src/github/views';
|
||||
|
||||
export interface RemoteInfo {
|
||||
owner: string;
|
||||
|
@ -111,6 +112,8 @@ export interface CreateParamsNew {
|
|||
isDarkTheme?: boolean;
|
||||
generateTitleAndDescriptionTitle: string | undefined;
|
||||
initializeWithGeneratedTitleAndDescription: boolean;
|
||||
preReviewState: PreReviewState;
|
||||
preReviewer: string | undefined;
|
||||
|
||||
validate?: boolean;
|
||||
showTitleValidationError?: boolean;
|
||||
|
|
|
@ -244,6 +244,18 @@ export interface TitleAndDescriptionProvider {
|
|||
provideTitleAndDescription(context: { commitMessages: string[], patches: string[] | { patch: string, fileUri: string, previousFileUri?: string }[], issues?: { reference: string, content: string }[] }, token: CancellationToken): Promise<{ title: string, description?: string } | undefined>;
|
||||
}
|
||||
|
||||
export interface ReviewerComments {
|
||||
// To tell which files we should add a comment icon in the "Files Changed" view
|
||||
files: Uri[];
|
||||
succeeded: boolean;
|
||||
// For removing comments
|
||||
disposable?: Disposable;
|
||||
}
|
||||
|
||||
export interface ReviewerCommentsProvider {
|
||||
provideReviewerComments(context: { repositoryRoot: string, commitMessages: string[], patches: { patch: string, fileUri: string, previousFileUri?: string }[] }, token: CancellationToken): Promise<ReviewerComments>;
|
||||
}
|
||||
|
||||
export interface API {
|
||||
/**
|
||||
* Register a [git provider](#IGit)
|
||||
|
@ -262,4 +274,9 @@ export interface API {
|
|||
* Register a PR title and description provider.
|
||||
*/
|
||||
registerTitleAndDescriptionProvider(title: string, provider: TitleAndDescriptionProvider): Disposable;
|
||||
|
||||
/**
|
||||
* Register a PR reviewer comments provider.
|
||||
*/
|
||||
registerReviewerCommentsProvider(title: string, provider: ReviewerCommentsProvider): Disposable;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import * as vscode from 'vscode';
|
|||
import { APIState, PublishEvent } from '../@types/git';
|
||||
import Logger from '../common/logger';
|
||||
import { TernarySearchTree } from '../common/utils';
|
||||
import { API, IGit, PostCommitCommandsProvider, Repository, TitleAndDescriptionProvider } from './api';
|
||||
import { API, IGit, PostCommitCommandsProvider, Repository, ReviewerCommentsProvider, TitleAndDescriptionProvider } from './api';
|
||||
|
||||
export const enum RefType {
|
||||
Head,
|
||||
|
@ -215,4 +215,18 @@ export class GitApiImpl implements API, IGit, vscode.Disposable {
|
|||
}
|
||||
}
|
||||
|
||||
private _reviewerCommentsProviders: Set<{ title: string, provider: ReviewerCommentsProvider }> = new Set();
|
||||
registerReviewerCommentsProvider(title: string, provider: ReviewerCommentsProvider): vscode.Disposable {
|
||||
const registeredValue = { title, provider };
|
||||
this._reviewerCommentsProviders.add(registeredValue);
|
||||
const disposable = {
|
||||
dispose: () => this._reviewerCommentsProviders.delete(registeredValue)
|
||||
};
|
||||
this._disposables.push(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
getReviewerCommentsProvider(): { title: string, provider: ReviewerCommentsProvider } | undefined {
|
||||
return this._reviewerCommentsProviders.size > 0 ? this._reviewerCommentsProviders.values().next().value : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ import { PullRequestModel } from './pullRequestModel';
|
|||
import { getDefaultMergeMethod } from './pullRequestOverview';
|
||||
import { getAssigneesQuickPickItems, getLabelOptions, getMilestoneFromQuickPick, getProjectFromQuickPick, reviewersQuickPick } from './quickPicks';
|
||||
import { getIssueNumberLabelFromParsed, ISSUE_EXPRESSION, ISSUE_OR_URL_EXPRESSION, parseIssueExpressionOutput, variableSubstitution } from './utils';
|
||||
import { PreReviewState } from './views';
|
||||
|
||||
const ISSUE_CLOSING_KEYWORDS = new RegExp('closes|closed|close|fixes|fixed|fix|resolves|resolved|resolve\s$', 'i'); // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword
|
||||
|
||||
|
@ -402,6 +403,7 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs
|
|||
*/
|
||||
this.telemetry.sendTelemetryEvent('pr.defaultTitleAndDescriptionProvider', { providerTitle: defaultTitleAndDescriptionProvider });
|
||||
}
|
||||
const preReviewer = this._folderRepositoryManager.getAutoReviewer();
|
||||
|
||||
this.labels = labels;
|
||||
|
||||
|
@ -424,7 +426,9 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs
|
|||
isDarkTheme: vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark,
|
||||
generateTitleAndDescriptionTitle: defaultTitleAndDescriptionProvider,
|
||||
creating: false,
|
||||
initializeWithGeneratedTitleAndDescription: useCopilot
|
||||
initializeWithGeneratedTitleAndDescription: useCopilot,
|
||||
preReviewState: PreReviewState.None,
|
||||
preReviewer: preReviewer?.title
|
||||
};
|
||||
|
||||
Logger.appendLine(`Initializing "create" view: ${JSON.stringify(params)}`, CreatePullRequestViewProvider.ID);
|
||||
|
@ -830,39 +834,44 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs
|
|||
return undefined;
|
||||
}
|
||||
|
||||
private async getCommitsAndPatches(): Promise<{ commitMessages: string[], patches: { patch: string, fileUri: string, previousFileUri?: string }[] }> {
|
||||
let commitMessages: string[];
|
||||
let patches: ({ patch: string, fileUri: string, previousFileUri?: string } | undefined)[];
|
||||
if (await this.model.getCompareHasUpstream()) {
|
||||
[commitMessages, patches] = await Promise.all([
|
||||
this.model.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)),
|
||||
this.model.gitHubFiles().then(rawPatches => rawPatches.map(file => {
|
||||
if (!file.patch) {
|
||||
return;
|
||||
}
|
||||
const fileUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.filename).toString();
|
||||
const previousFileUri = file.previous_filename ? vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.previous_filename).toString() : undefined;
|
||||
return { patch: file.patch, fileUri, previousFileUri };
|
||||
}))]);
|
||||
} else {
|
||||
[commitMessages, patches] = await Promise.all([
|
||||
this.model.gitCommits().then(rawCommits => rawCommits.filter(commit => commit.parents.length === 1).map(commit => commit.message)),
|
||||
Promise.all((await this.model.gitFiles()).map(async (file) => {
|
||||
return {
|
||||
patch: await this._folderRepositoryManager.repository.diffBetween(this.model.baseBranch, this.model.compareBranch, file.uri.fsPath),
|
||||
fileUri: file.uri.toString(),
|
||||
};
|
||||
}))]);
|
||||
}
|
||||
const filteredPatches: { patch: string, fileUri: string, previousFileUri?: string }[] =
|
||||
patches.filter<{ patch: string, fileUri: string, previousFileUri?: string }>((patch): patch is { patch: string, fileUri: string, previousFileUri?: string } => !!patch);
|
||||
return { commitMessages, patches: filteredPatches };
|
||||
}
|
||||
|
||||
private lastGeneratedTitleAndDescription: { title?: string, description?: string, providerTitle: string } | undefined;
|
||||
private async getTitleAndDescriptionFromProvider(token: vscode.CancellationToken, searchTerm?: string) {
|
||||
return CreatePullRequestViewProvider.withProgress(async () => {
|
||||
try {
|
||||
let commitMessages: string[];
|
||||
let patches: ({ patch: string, fileUri: string, previousFileUri?: string } | undefined)[];
|
||||
if (await this.model.getCompareHasUpstream()) {
|
||||
[commitMessages, patches] = await Promise.all([
|
||||
this.model.gitHubCommits().then(rawCommits => rawCommits.map(commit => commit.commit.message)),
|
||||
this.model.gitHubFiles().then(rawPatches => rawPatches.map(file => {
|
||||
if (!file.patch) {
|
||||
return;
|
||||
}
|
||||
const fileUri = vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.filename).toString();
|
||||
const previousFileUri = file.previous_filename ? vscode.Uri.joinPath(this._folderRepositoryManager.repository.rootUri, file.previous_filename).toString() : undefined;
|
||||
return { patch: file.patch, fileUri, previousFileUri };
|
||||
}))]);
|
||||
} else {
|
||||
[commitMessages, patches] = await Promise.all([
|
||||
this.model.gitCommits().then(rawCommits => rawCommits.filter(commit => commit.parents.length === 1).map(commit => commit.message)),
|
||||
Promise.all((await this.model.gitFiles()).map(async (file) => {
|
||||
return {
|
||||
patch: await this._folderRepositoryManager.repository.diffBetween(this.model.baseBranch, this.model.compareBranch, file.uri.fsPath),
|
||||
fileUri: file.uri.toString(),
|
||||
};
|
||||
}))]);
|
||||
}
|
||||
const filteredPatches: { patch: string, fileUri: string, previousFileUri?: string }[] =
|
||||
patches.filter<{ patch: string, fileUri: string, previousFileUri?: string }>((patch): patch is { patch: string, fileUri: string, previousFileUri?: string } => !!patch);
|
||||
const { commitMessages, patches } = await this.getCommitsAndPatches();
|
||||
const issues = await this.findIssueContext(commitMessages);
|
||||
|
||||
const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider(searchTerm);
|
||||
const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches: filteredPatches, issues }, token);
|
||||
const result = await provider?.provider.provideTitleAndDescription({ commitMessages, patches, issues }, token);
|
||||
|
||||
if (provider) {
|
||||
this.lastGeneratedTitleAndDescription = { ...result, providerTitle: provider.title };
|
||||
|
@ -908,6 +917,38 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs
|
|||
}
|
||||
}
|
||||
|
||||
private async getPreReviewFromProvider(token: vscode.CancellationToken): Promise<PreReviewState | undefined> {
|
||||
const preReviewer = this._folderRepositoryManager.getAutoReviewer();
|
||||
if (!preReviewer) {
|
||||
return;
|
||||
}
|
||||
const { commitMessages, patches } = await this.getCommitsAndPatches();
|
||||
const result = await preReviewer.provider.provideReviewerComments({ repositoryRoot: this._folderRepositoryManager.repository.rootUri.fsPath, commitMessages, patches }, token);
|
||||
return (result && result.succeeded && result.files.length > 0) ? PreReviewState.ReviewedWithComments : PreReviewState.ReviewedWithoutComments;
|
||||
}
|
||||
|
||||
private reviewingCancellationToken: vscode.CancellationTokenSource | undefined;
|
||||
private async preReview(message: IRequestMessage<any>): Promise<void> {
|
||||
if (this.reviewingCancellationToken) {
|
||||
this.reviewingCancellationToken.cancel();
|
||||
}
|
||||
this.reviewingCancellationToken = new vscode.CancellationTokenSource();
|
||||
|
||||
|
||||
const result = await Promise.race([this.getPreReviewFromProvider(this.reviewingCancellationToken.token),
|
||||
new Promise<void>(resolve => this.reviewingCancellationToken?.token.onCancellationRequested(() => resolve()))]);
|
||||
|
||||
this.reviewingCancellationToken = undefined;
|
||||
|
||||
return this._replyMessage(message, result);
|
||||
}
|
||||
|
||||
private async cancelPreReview(): Promise<void> {
|
||||
if (this.reviewingCancellationToken) {
|
||||
this.reviewingCancellationToken.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private async pushUpstream(compareOwner: string, compareRepositoryName: string, compareBranchName: string): Promise<{ compareUpstream: GitHubRemote, repo: GitHubRepository | undefined } | undefined> {
|
||||
let createdPushRemote: GitHubRemote | undefined;
|
||||
const pushRemote = this._folderRepositoryManager.repository.state.remotes.find(localRemote => {
|
||||
|
@ -1175,6 +1216,12 @@ export class CreatePullRequestViewProvider extends WebviewViewBase implements vs
|
|||
case 'pr.cancelGenerateTitleAndDescription':
|
||||
return this.cancelGenerateTitleAndDescription();
|
||||
|
||||
case 'pr.preReview':
|
||||
return this.preReview(message);
|
||||
|
||||
case 'pr.cancelPreReview':
|
||||
return this.cancelPreReview();
|
||||
|
||||
default:
|
||||
// Log error
|
||||
vscode.window.showErrorMessage('Unsupported webview message');
|
||||
|
|
|
@ -2551,6 +2551,10 @@ export class FolderRepositoryManager implements vscode.Disposable {
|
|||
return this._git.getTitleAndDescriptionProvider(searchTerm);
|
||||
}
|
||||
|
||||
public getAutoReviewer() {
|
||||
return this._git.getReviewerCommentsProvider();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._subs.forEach(sub => sub.dispose());
|
||||
this._onDidDispose.fire();
|
||||
|
|
|
@ -101,4 +101,11 @@ export interface MergeArguments {
|
|||
description: string | undefined;
|
||||
method: MergeMethod;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export enum PreReviewState {
|
||||
None = 0,
|
||||
Available,
|
||||
ReviewedWithComments,
|
||||
ReviewedWithoutComments
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { createContext } from 'react';
|
||||
import { ChooseBaseRemoteAndBranchResult, ChooseCompareRemoteAndBranchResult, ChooseRemoteAndBranchArgs, CreateParamsNew, CreatePullRequestNew, RemoteInfo, ScrollPosition, TitleAndDescriptionArgs, TitleAndDescriptionResult } from '../../common/views';
|
||||
import { PreReviewState } from '../../src/github/views';
|
||||
import { getMessageHandler, MessageHandler, vscode } from './message';
|
||||
|
||||
const defaultCreateParams: CreateParamsNew = {
|
||||
|
@ -27,7 +28,9 @@ const defaultCreateParams: CreateParamsNew = {
|
|||
creating: false,
|
||||
generateTitleAndDescriptionTitle: undefined,
|
||||
initializeWithGeneratedTitleAndDescription: false,
|
||||
baseHasMergeQueue: false
|
||||
baseHasMergeQueue: false,
|
||||
preReviewState: PreReviewState.None,
|
||||
preReviewer: undefined
|
||||
};
|
||||
|
||||
export class CreatePRContextNew {
|
||||
|
@ -178,6 +181,15 @@ export class CreatePRContextNew {
|
|||
}
|
||||
}
|
||||
|
||||
public preReview = async (): Promise<void> => {
|
||||
const result: PreReviewState = await this.postMessage({ command: 'pr.preReview' });
|
||||
this.updateState({ preReviewState: result });
|
||||
}
|
||||
|
||||
public cancelPreReview = async (): Promise<void> => {
|
||||
return this.postMessage({ command: 'pr.cancelPreReview' });
|
||||
}
|
||||
|
||||
public validate = (): boolean => {
|
||||
let isValid = true;
|
||||
if (!this.createParams.pendingTitle) {
|
||||
|
|
|
@ -41,6 +41,7 @@ export function main() {
|
|||
const ctx = useContext(PullRequestContextNew);
|
||||
const [isBusy, setBusy] = useState(params.creating);
|
||||
const [isGeneratingTitle, setGeneratingTitle] = useState(false);
|
||||
const [isReviewing, setReviewing] = useState(false);
|
||||
function createMethodLabel(isDraft?: boolean, autoMerge?: boolean, autoMergeMethod?: MergeMethod, baseHasMergeQueue?: boolean): { value: CreateMethod, label: string } {
|
||||
let value: CreateMethod;
|
||||
let label: string;
|
||||
|
@ -187,6 +188,12 @@ export function main() {
|
|||
setGeneratingTitle(false);
|
||||
}
|
||||
|
||||
async function preReview() {
|
||||
setReviewing(true);
|
||||
await ctx.preReview();
|
||||
setReviewing(false);
|
||||
}
|
||||
|
||||
if (!ctx.initialized) {
|
||||
ctx.initialize();
|
||||
}
|
||||
|
@ -240,7 +247,7 @@ export function main() {
|
|||
onChange={(e) => updateTitle(e.currentTarget.value)}
|
||||
onKeyDown={(e) => onKeyDown(true, e)}
|
||||
data-vscode-context='{"preventDefaultContextMenuItems": false}'
|
||||
disabled={!ctx.initialized || isBusy || isGeneratingTitle}>
|
||||
disabled={!ctx.initialized || isBusy || isGeneratingTitle || isReviewing}>
|
||||
</input>
|
||||
{ctx.createParams.generateTitleAndDescriptionTitle ?
|
||||
isGeneratingTitle ?
|
||||
|
@ -337,7 +344,7 @@ export function main() {
|
|||
onChange={(e) => ctx.updateState({ pendingDescription: e.currentTarget.value })}
|
||||
onKeyDown={(e) => onKeyDown(false, e)}
|
||||
data-vscode-context='{"preventDefaultContextMenuItems": false}'
|
||||
disabled={!ctx.initialized || isBusy || isGeneratingTitle}></textarea>
|
||||
disabled={!ctx.initialized || isBusy || isGeneratingTitle || isReviewing}></textarea>
|
||||
</div>
|
||||
|
||||
<div className={params.validate && !!params.createError ? 'wrapper validation-error' : 'hidden'} aria-live='assertive'>
|
||||
|
@ -346,6 +353,19 @@ export function main() {
|
|||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{params.preReviewer ?
|
||||
<div className='pre-review'>
|
||||
{isReviewing ?
|
||||
<a title='Cancel review' onClick={ctx.cancelPreReview} className={`auto-review ${isBusy || isGeneratingTitle || !ctx.initialized ? ' disabled' : ''}`}>
|
||||
{stopIcon} Cancel review
|
||||
</a>
|
||||
: <a title={`Pre-review with ${params.preReviewer}`} onClick={preReview} className={`auto-review ${isBusy || isGeneratingTitle || !ctx.initialized ? ' disabled' : ''}`}>
|
||||
{sparkleIcon} Pre-review with {params.preReviewer}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
: null}
|
||||
|
||||
<div className='group-actions'>
|
||||
<button disabled={isBusy} className='secondary' onClick={() => ctx.cancelCreate()}>
|
||||
Cancel
|
||||
|
@ -356,7 +376,7 @@ export function main() {
|
|||
defaultOptionLabel={() => createMethodLabel(ctx.createParams.isDraft, ctx.createParams.autoMerge, ctx.createParams.autoMergeMethod, ctx.createParams.baseHasMergeQueue).label}
|
||||
defaultOptionValue={() => createMethodLabel(ctx.createParams.isDraft, ctx.createParams.autoMerge, ctx.createParams.autoMergeMethod, ctx.createParams.baseHasMergeQueue).value}
|
||||
optionsTitle='Create with Option'
|
||||
disabled={isBusy || isGeneratingTitle || !isCreateable || !ctx.initialized}
|
||||
disabled={isBusy || isGeneratingTitle || isReviewing || !isCreateable || !ctx.initialized}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
|
|
@ -119,7 +119,7 @@ button.input-box {
|
|||
padding: 2px;
|
||||
}
|
||||
|
||||
.group-title .title-action.disabled svg path {
|
||||
.group-title .disabled svg path {
|
||||
fill: var(--vscode-disabledForeground);
|
||||
}
|
||||
|
||||
|
@ -254,4 +254,27 @@ textarea {
|
|||
|
||||
.dropdown-container {
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
/* Auto review */
|
||||
.pre-review {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auto-review {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-review.disabled:hover,
|
||||
.auto-review.disabled {
|
||||
cursor: default;
|
||||
color: var(--vscode-disabledForeground);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pre-review svg path {
|
||||
fill: currentColor;
|
||||
}
|
Загрузка…
Ссылка в новой задаче