Skip to content

Commit a6b7583

Browse files
Copilotalexr00
andcommitted
Add description generation for existing PRs with sparkle icon UI
Co-authored-by: alexr00 <38270282+alexr00@users.noreply.github.com>
1 parent e7a11ac commit a6b7583

6 files changed

Lines changed: 175 additions & 6 deletions

File tree

common/views.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ export interface TitleAndDescriptionResult {
165165
description: string | undefined;
166166
}
167167

168+
export interface DescriptionResult {
169+
description: string | undefined;
170+
}
171+
168172
export interface CloseResult {
169173
state: GithubItemStateEnum;
170174
commentEvent?: CommentEvent;

src/github/pullRequestOverview.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
335335
emailForCommit,
336336
currentUserReviewState: reviewState,
337337
revertable: pullRequest.state === GithubItemStateEnum.Merged,
338-
isCopilotOnMyBehalf: await isCopilotOnMyBehalf(pullRequest, currentUser, coAuthors)
338+
isCopilotOnMyBehalf: await isCopilotOnMyBehalf(pullRequest, currentUser, coAuthors),
339+
generateDescriptionTitle: this.getGenerateDescriptionTitle()
339340
};
340341
this._postMessage({
341342
command: 'pr.initialize',
@@ -425,6 +426,10 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
425426
return this.openCommitChanges(message);
426427
case 'pr.delete-review':
427428
return this.deleteReview(message);
429+
case 'pr.generate-description':
430+
return this.generateDescription(message);
431+
case 'pr.cancel-generate-description':
432+
return this.cancelGenerateDescription();
428433
}
429434
}
430435

@@ -805,6 +810,54 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
805810
}
806811
}
807812

813+
private getGenerateDescriptionTitle(): string | undefined {
814+
const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider();
815+
return provider ? `Generate description with ${provider.title}` : undefined;
816+
}
817+
818+
private generatingDescriptionCancellationToken: vscode.CancellationTokenSource | undefined;
819+
820+
private async generateDescription(message: IRequestMessage<void>): Promise<void> {
821+
if (this.generatingDescriptionCancellationToken) {
822+
this.generatingDescriptionCancellationToken.cancel();
823+
}
824+
this.generatingDescriptionCancellationToken = new vscode.CancellationTokenSource();
825+
826+
try {
827+
const provider = this._folderRepositoryManager.getTitleAndDescriptionProvider();
828+
if (!provider) {
829+
return this._replyMessage(message, { description: undefined });
830+
}
831+
832+
// Get commits and patches for the PR
833+
const commits = await this._item.getCommits();
834+
const commitMessages = commits.map(commit => commit.commit.message);
835+
const patches: string[] = [];
836+
837+
// Get the PR template
838+
const templateContent = await this._folderRepositoryManager.getPullRequestTemplateBody(this._item.remote.owner);
839+
840+
const result = await provider.provider.provideTitleAndDescription(
841+
{ commitMessages, patches, issues: [], template: templateContent },
842+
this.generatingDescriptionCancellationToken.token
843+
);
844+
845+
this.generatingDescriptionCancellationToken = undefined;
846+
return this._replyMessage(message, { description: result?.description });
847+
} catch (e) {
848+
Logger.error(`Error generating description: ${formatError(e)}`, PullRequestOverviewPanel.ID);
849+
this.generatingDescriptionCancellationToken = undefined;
850+
return this._replyMessage(message, { description: undefined });
851+
}
852+
}
853+
854+
private async cancelGenerateDescription(): Promise<void> {
855+
if (this.generatingDescriptionCancellationToken) {
856+
this.generatingDescriptionCancellationToken.cancel();
857+
this.generatingDescriptionCancellationToken = undefined;
858+
}
859+
}
860+
808861
override dispose() {
809862
super.dispose();
810863
disposeAll(this._prListeners);

src/github/views.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export interface PullRequest extends Issue {
109109
revertable?: boolean;
110110
busy?: boolean;
111111
loadingCommit?: string;
112+
generateDescriptionTitle?: string;
112113
}
113114

114115
export interface ProjectItemsReply {

webviews/common/context.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { createContext } from 'react';
77
import { getState, setState, updateState } from './cache';
88
import { getMessageHandler, MessageHandler } from './message';
9-
import { CloseResult, OpenCommitChangesArgs } from '../../common/views';
9+
import { CloseResult, DescriptionResult, OpenCommitChangesArgs } from '../../common/views';
1010
import { IComment } from '../../src/common/comment';
1111
import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent';
1212
import { IProjectItem, MergeMethod, ReadyForReview } from '../../src/github/interface';
@@ -135,6 +135,12 @@ export class PRContext {
135135
public editComment = (args: { comment: IComment; text: string }) =>
136136
this.postMessage({ command: 'pr.edit-comment', args });
137137

138+
public generateDescription = (): Promise<DescriptionResult> =>
139+
this.postMessage({ command: 'pr.generate-description' });
140+
141+
public cancelGenerateDescription = () =>
142+
this.postMessage({ command: 'pr.cancel-generate-description' });
143+
138144
public updateDraft = (id: number, body: string) => {
139145
const pullRequest = getState();
140146
const pendingCommentDrafts = pullRequest.pendingCommentDrafts || Object.create(null);

webviews/components/comment.tsx

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
77
import { ContextDropdown } from './contextDropdown';
8-
import { copyIcon, editIcon, quoteIcon, trashIcon } from './icon';
8+
import { copyIcon, editIcon, quoteIcon, sparkleIcon, stopCircleIcon, trashIcon } from './icon';
99
import { nbsp, Spaced } from './space';
1010
import { Timestamp } from './timestamp';
1111
import { AuthorLink, Avatar } from './user';
@@ -56,6 +56,7 @@ export function CommentView(commentProps: Props) {
5656
id={id}
5757
key={`editComment${id}`}
5858
body={currentDraft || bodyMd}
59+
isPRDescription={isPRDescription}
5960
onCancel={() => {
6061
if (pr?.pendingCommentDrafts) {
6162
delete pr.pendingCommentDrafts[id];
@@ -208,14 +209,17 @@ type FormInputSet = {
208209
type EditCommentProps = {
209210
id: number;
210211
body: string;
212+
isPRDescription?: boolean;
211213
onCancel: () => void;
212214
onSave: (body: string) => Promise<any>;
213215
};
214216

215-
function EditComment({ id, body, onCancel, onSave }: EditCommentProps) {
216-
const { updateDraft } = useContext(PullRequestContext);
217+
function EditComment({ id, body, isPRDescription, onCancel, onSave }: EditCommentProps) {
218+
const { updateDraft, pr, generateDescription, cancelGenerateDescription } = useContext(PullRequestContext);
217219
const draftComment = useRef<{ body: string; dirty: boolean }>({ body, dirty: false });
218220
const form = useRef<HTMLFormElement>();
221+
const [isGenerating, setIsGenerating] = useState(false);
222+
const [canGenerate, setCanGenerate] = useState(false);
219223

220224
useEffect(() => {
221225
const interval = setInterval(() => {
@@ -227,6 +231,13 @@ function EditComment({ id, body, onCancel, onSave }: EditCommentProps) {
227231
return () => clearInterval(interval);
228232
}, [draftComment]);
229233

234+
useEffect(() => {
235+
// Check if description generation is available
236+
if (isPRDescription && pr?.generateDescriptionTitle) {
237+
setCanGenerate(true);
238+
}
239+
}, [isPRDescription, pr?.generateDescriptionTitle]);
240+
230241
const submit = useCallback(async () => {
231242
const { markdown, submitButton }: FormInputSet = form.current!;
232243
submitButton.disabled = true;
@@ -263,9 +274,57 @@ function EditComment({ id, body, onCancel, onSave }: EditCommentProps) {
263274
[draftComment],
264275
);
265276

277+
const handleGenerateDescription = useCallback(async () => {
278+
if (!generateDescription) {
279+
return;
280+
}
281+
setIsGenerating(true);
282+
try {
283+
const generated = await generateDescription();
284+
if (generated?.description && form.current) {
285+
const textarea = form.current.markdown as HTMLTextAreaElement;
286+
textarea.value = generated.description;
287+
draftComment.current.body = generated.description;
288+
draftComment.current.dirty = true;
289+
}
290+
} finally {
291+
setIsGenerating(false);
292+
}
293+
}, [generateDescription]);
294+
295+
const handleCancelGenerate = useCallback(() => {
296+
if (cancelGenerateDescription) {
297+
cancelGenerateDescription();
298+
}
299+
setIsGenerating(false);
300+
}, [cancelGenerateDescription]);
301+
266302
return (
267303
<form ref={form as React.MutableRefObject<HTMLFormElement>} onSubmit={onSubmit}>
268-
<textarea name="markdown" defaultValue={body} onKeyDown={onKeyDown} onInput={onInput} />
304+
<div className="textarea-wrapper">
305+
<textarea name="markdown" defaultValue={body} onKeyDown={onKeyDown} onInput={onInput} disabled={isGenerating} />
306+
{canGenerate && isPRDescription ? (
307+
isGenerating ? (
308+
<button
309+
type="button"
310+
title="Cancel"
311+
className="title-action icon-button"
312+
onClick={handleCancelGenerate}
313+
>
314+
{stopCircleIcon}
315+
</button>
316+
) : (
317+
<button
318+
type="button"
319+
title={pr?.generateDescriptionTitle || 'Generate description'}
320+
className="title-action icon-button"
321+
onClick={handleGenerateDescription}
322+
>
323+
{sparkleIcon}
324+
</button>
325+
)
326+
) : null}
327+
</div>
269328
<div className="form-actions">
270329
<button className="secondary" onClick={onCancel}>
271330
Cancel

webviews/editorWebview/index.css

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,52 @@ textarea {
872872
max-height: 500px;
873873
}
874874

875+
.textarea-wrapper {
876+
position: relative;
877+
display: flex;
878+
width: 100%;
879+
}
880+
881+
.textarea-wrapper textarea {
882+
flex: 1;
883+
padding-right: 40px;
884+
}
885+
886+
.textarea-wrapper .title-action {
887+
position: absolute;
888+
top: 6px;
889+
right: 5px;
890+
border: none;
891+
background: none;
892+
padding: 4px;
893+
display: flex;
894+
align-items: center;
895+
justify-content: center;
896+
border-radius: 4px;
897+
}
898+
899+
.textarea-wrapper .title-action:hover {
900+
outline-style: none;
901+
cursor: pointer;
902+
background-color: var(--vscode-toolbar-hoverBackground);
903+
}
904+
905+
.textarea-wrapper .title-action:focus {
906+
outline-style: none;
907+
}
908+
909+
.textarea-wrapper .title-action:focus-visible {
910+
outline-width: 1px;
911+
outline-style: solid;
912+
outline-offset: -1px;
913+
outline-color: var(--vscode-focusBorder);
914+
background: unset;
915+
}
916+
917+
.textarea-wrapper .title-action svg {
918+
padding: 2px;
919+
}
920+
875921
.editing-form {
876922
padding: 5px 0;
877923
display: flex;

0 commit comments

Comments
 (0)