Add very basic support for allowing another extension to provide a review (#6055)

This commit is contained in:
Alex Ross 2024-06-21 18:31:27 +02:00 коммит произвёл GitHub
Родитель 496e293e6f
Коммит 4cf2e88cd6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
9 изменённых файлов: 180 добавлений и 33 удалений

Просмотреть файл

@ -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;

17
src/api/api.d.ts поставляемый
Просмотреть файл

@ -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;
}