Skip to content

Commit d4b84e8

Browse files
authored
Merge branch 'main' into pawang/improveGitBranchNaming
2 parents 29f10da + 46258c8 commit d4b84e8

8 files changed

Lines changed: 172 additions & 32 deletions

File tree

src/github/copilotApi.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const LEARN_MORE_URL = 'https://aka.ms/coding-agent-docs';
2121
const PREMIUM_REQUESTS_URL = 'https://docs.github.com/en/copilot/concepts/copilot-billing/understanding-and-managing-requests-in-copilot#what-are-premium-requests';
2222
// https://github.com/github/sweagentd/blob/59e7d9210ca3ebba029918387e525eea73cb1f4a/internal/problemstatement/problemstatement.go#L36-L53
2323
export const MAX_PROBLEM_STATEMENT_LENGTH = 30_000 - 50; // 50 character buffer
24+
// https://github.com/github/sweagentd/blob/0ad8f81a9c64754cb8a83d10777de4638bba1a6e/docs/adr/0001-create-job-api.md#post-jobsownerrepo---create-job-task
25+
const JOBS_API_VERSION = 'v1';
26+
2427
export interface RemoteAgentJobPayload {
2528
problem_statement: string;
2629
event_type: string;
@@ -35,11 +38,14 @@ export interface RemoteAgentJobPayload {
3538
}
3639

3740
export interface RemoteAgentJobResponse {
38-
pull_request: {
39-
html_url: string;
40-
number: number;
41-
}
41+
job_id: string;
4242
session_id: string;
43+
actor: {
44+
id: number;
45+
login: string;
46+
};
47+
created_at: string;
48+
updated_at: string;
4349
}
4450

4551
export interface ChatSessionWithPR extends vscode.ChatSessionItem {
@@ -73,15 +79,14 @@ export class CopilotApi {
7379
return `vscode-pull-request-github/${extensionVersion}`;
7480
}
7581

76-
7782
async postRemoteAgentJob(
7883
owner: string,
7984
name: string,
8085
payload: RemoteAgentJobPayload,
8186
isTruncated: boolean,
8287
): Promise<RemoteAgentJobResponse> {
8388
const repoSlug = `${owner}/${name}`;
84-
const apiUrl = `/agents/swe/v0/jobs/${repoSlug}`;
89+
const apiUrl = `/agents/swe/${JOBS_API_VERSION}/jobs/${repoSlug}`;
8590
let status: number | undefined;
8691

8792
const problemStatementLength = payload.problem_statement.length.toString();
@@ -169,18 +174,27 @@ export class CopilotApi {
169174
if (!data || typeof data !== 'object') {
170175
throw new Error('Invalid response from coding agent');
171176
}
172-
if (!data.pull_request || typeof data.pull_request !== 'object') {
173-
throw new Error('Invalid pull_request in response');
174-
}
175-
if (typeof data.pull_request.html_url !== 'string') {
176-
throw new Error('Invalid pull_request.html_url in response');
177-
}
178-
if (typeof data.pull_request.number !== 'number') {
179-
throw new Error('Invalid pull_request.number in response');
177+
if (typeof data.job_id !== 'string') {
178+
throw new Error('Invalid job_id in response');
180179
}
181180
if (typeof data.session_id !== 'string') {
182181
throw new Error('Invalid session_id in response');
183182
}
183+
if (!data.actor || typeof data.actor !== 'object') {
184+
throw new Error('Invalid actor in response');
185+
}
186+
if (typeof data.actor.id !== 'number') {
187+
throw new Error('Invalid actor.id in response');
188+
}
189+
if (typeof data.actor.login !== 'string') {
190+
throw new Error('Invalid actor.login in response');
191+
}
192+
if (typeof data.created_at !== 'string') {
193+
throw new Error('Invalid created_at in response');
194+
}
195+
if (typeof data.updated_at !== 'string') {
196+
throw new Error('Invalid updated_at in response');
197+
}
184198
}
185199

186200
public async getLogsFromZipUrl(logsUrl: string): Promise<string[]> {
@@ -270,9 +284,32 @@ export class CopilotApi {
270284
return await logsResponse.text();
271285
}
272286

287+
public async getJobByJobId(owner: string, repo: string, jobId: string): Promise<JobInfo | undefined> {
288+
try {
289+
const response = await this.makeApiCall(`/agents/swe/v1/jobs/${owner}/${repo}/${jobId}`, {
290+
method: 'GET',
291+
headers: {
292+
'Authorization': `Bearer ${this.token}`,
293+
'Content-Type': 'application/json',
294+
'Accept': 'application/json',
295+
'User-Agent': this.userAgent,
296+
}
297+
});
298+
if (!response.ok) {
299+
Logger.warn(`Failed to fetch job info for job ${jobId}: ${response.statusText}`, CopilotApi.ID);
300+
return;
301+
}
302+
const data = await response.json() as JobInfo;
303+
return data;
304+
} catch (error) {
305+
Logger.warn(`Error fetching job info for job ${jobId}: ${error}`, CopilotApi.ID);
306+
return;
307+
}
308+
}
309+
273310
public async getJobBySessionId(owner: string, repo: string, sessionId: string): Promise<JobInfo | undefined> {
274311
try {
275-
const response = await this.makeApiCall(`/agents/swe/v0/jobs/${owner}/${repo}/session/${sessionId}`, {
312+
const response = await this.makeApiCall(`/agents/swe/${JOBS_API_VERSION}/jobs/${owner}/${repo}/session/${sessionId}`, {
276313
method: 'GET',
277314
headers: {
278315
'Authorization': `Bearer ${this.token}`,

src/github/copilotRemoteAgent.ts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { CODING_AGENT, CODING_AGENT_AUTO_COMMIT_AND_PUSH } from '../common/setti
1919
import { ITelemetry } from '../common/telemetry';
2020
import { toOpenPullRequestWebviewUri } from '../common/uri';
2121
import { copilotPRStatusToSessionStatus, IAPISessionLogs, ICopilotRemoteAgentCommandArgs, ICopilotRemoteAgentCommandResponse, OctokitCommon, RemoteAgentResult, RepoInfo } from './common';
22-
import { ChatSessionWithPR, CopilotApi, getCopilotApi, RemoteAgentJobPayload, SessionInfo, SessionSetupStep } from './copilotApi';
22+
import { ChatSessionWithPR, CopilotApi, getCopilotApi, JobInfo, RemoteAgentJobPayload, SessionInfo, SessionSetupStep } from './copilotApi';
2323
import { CodingAgentPRAndStatus, CopilotPRWatcher, CopilotStateModel } from './copilotPrWatcher';
2424
import { ChatSessionContentBuilder } from './copilotRemoteAgent/chatSessionContentBuilder';
2525
import { GitOperationsManager } from './copilotRemoteAgent/gitOperationsManager';
@@ -94,7 +94,6 @@ export class CopilotRemoteAgentManager extends Disposable {
9494
historySummaryLength: historySummary?.length.toString() || '0',
9595
source,
9696
});
97-
stream.progress(vscode.l10n.t('Delegating to coding agent'));
9897
const result = await this.invokeRemoteAgent(
9998
promptSummary || prompt,
10099
[
@@ -103,6 +102,7 @@ export class CopilotRemoteAgentManager extends Disposable {
103102
].join('\n\n').trim(),
104103
token,
105104
false,
105+
stream,
106106
);
107107
if (result.state !== 'success') {
108108
Logger.error(`Failed to provide new chat session item: ${result.error}`, CopilotRemoteAgentManager.ID);
@@ -194,7 +194,7 @@ export class CopilotRemoteAgentManager extends Disposable {
194194
stream.markdown(result);
195195
stream.markdown('\n\n');
196196

197-
stream.progress(vscode.l10n.t('Waiting for coding agent to respond'));
197+
stream.progress(vscode.l10n.t('Attaching to session'));
198198

199199
// Wait for new session and stream its progress
200200
const newSession = await this.waitForNewSession(pullRequest, stream, token, true);
@@ -705,7 +705,7 @@ export class CopilotRemoteAgentManager extends Disposable {
705705
return vscode.l10n.t('🚀 Coding agent will continue work in [#{0}]({1}). Track progress [here]({2}).', number, link, webviewUri.toString());
706706
}
707707

708-
async invokeRemoteAgent(prompt: string, problemContext?: string, token?: vscode.CancellationToken, autoPushAndCommit = true): Promise<RemoteAgentResult> {
708+
async invokeRemoteAgent(prompt: string, problemContext?: string, token?: vscode.CancellationToken, autoPushAndCommit = true, chatStream?: vscode.ChatResponseStream): Promise<RemoteAgentResult> {
709709
const capiClient = await this.copilotApi;
710710
if (!capiClient) {
711711
return { error: vscode.l10n.t('Failed to initialize Copilot API'), state: 'error' };
@@ -731,6 +731,7 @@ export class CopilotRemoteAgentManager extends Disposable {
731731
return { error: vscode.l10n.t('Uncommitted changes detected. Please commit or stash your changes before starting the remote agent. Enable \'{0}\' to push your changes automatically.', CODING_AGENT_AUTO_COMMIT_AND_PUSH), state: 'error' };
732732
}
733733
try {
734+
chatStream?.progress(vscode.l10n.t('Waiting for local changes'));
734735
head_ref = await this.gitOperationsManager.commitAndPushChanges(repoInfo);
735736
} catch (error) {
736737
return { error: error.message, state: 'error' };
@@ -755,6 +756,7 @@ export class CopilotRemoteAgentManager extends Disposable {
755756
const { problemStatement, isTruncated } = truncatePrompt(prompt, problemContext);
756757

757758
if (isTruncated) {
759+
chatStream?.progress(vscode.l10n.t('Truncating context'));
758760
const truncationResult = await vscode.window.showWarningMessage(
759761
vscode.l10n.t('Prompt size exceeded'), { modal: true, detail: vscode.l10n.t('Your prompt will be truncated to fit within coding agent\'s context window. This may affect the quality of the response.') }, CONTINUE_TRUNCATION);
760762
const userCancelled = token?.isCancellationRequested || !truncationResult || truncationResult !== CONTINUE_TRUNCATION;
@@ -784,19 +786,36 @@ export class CopilotRemoteAgentManager extends Disposable {
784786
};
785787

786788
try {
787-
const { pull_request, session_id } = await capiClient.postRemoteAgentJob(owner, repo, payload, isTruncated);
788-
this._onDidCreatePullRequest.fire(pull_request.number);
789-
const webviewUri = await toOpenPullRequestWebviewUri({ owner, repo, pullRequestNumber: pull_request.number });
789+
chatStream?.progress(vscode.l10n.t('Delegating to coding agent'));
790+
const response = await capiClient.postRemoteAgentJob(owner, repo, payload, isTruncated);
791+
792+
// For v1 API, we need to fetch the job details to get the PR info
793+
// Since the PR might not be created immediately, we need to poll for it
794+
chatStream?.progress(vscode.l10n.t('Creating pull request'));
795+
const jobInfo = await this.waitForJobWithPullRequest(capiClient, owner, repo, response.job_id, token);
796+
if (!jobInfo || !jobInfo.pull_request) {
797+
return { error: vscode.l10n.t('Failed to retrieve pull request information from job'), state: 'error' };
798+
}
799+
800+
const { number } = jobInfo.pull_request;
801+
this._onDidCreatePullRequest.fire(number);
802+
803+
// Find the actual PR to get the HTML URL
804+
const pullRequest = await this.findPullRequestById(number, true);
805+
const htmlUrl = pullRequest?.html_url || `https://github.com/${owner}/${repo}/pull/${number}`;
806+
807+
const webviewUri = await toOpenPullRequestWebviewUri({ owner, repo, pullRequestNumber: number });
790808
const prLlmString = `The remote agent has begun work and has created a pull request. Details about the pull request are being shown to the user. If the user wants to track progress or iterate on the agent's work, they should use the pull request.`;
791809

792-
await this.waitForQueuedToInProgress(session_id, token);
810+
chatStream?.progress(vscode.l10n.t('Attaching to session'));
811+
await this.waitForQueuedToInProgress(response.session_id, token);
793812
return {
794813
state: 'success',
795-
number: pull_request.number,
796-
link: pull_request.html_url,
814+
number,
815+
link: htmlUrl,
797816
webviewUri,
798817
llmDetails: head_ref ? `Local pending changes have been pushed to branch '${head_ref}'. ${prLlmString}` : prLlmString,
799-
sessionId: session_id
818+
sessionId: response.session_id
800819
};
801820
} catch (error) {
802821
return { error: error.message, state: 'error' };
@@ -1585,6 +1604,32 @@ export class CopilotRemoteAgentManager extends Disposable {
15851604
this._stateModel.clear();
15861605
}
15871606

1607+
private async waitForJobWithPullRequest(
1608+
capiClient: CopilotApi,
1609+
owner: string,
1610+
repo: string,
1611+
jobId: string,
1612+
token?: vscode.CancellationToken
1613+
): Promise<JobInfo | undefined> {
1614+
const maxWaitTime = 30 * 1000; // 30 seconds
1615+
const pollInterval = 2000; // 2 seconds
1616+
const startTime = Date.now();
1617+
1618+
Logger.appendLine(`Waiting for job ${jobId} to have pull request information...`, CopilotRemoteAgentManager.ID);
1619+
1620+
while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) {
1621+
const jobInfo = await capiClient.getJobByJobId(owner, repo, jobId);
1622+
if (jobInfo && jobInfo.pull_request && jobInfo.pull_request.number) {
1623+
Logger.appendLine(`Job ${jobId} now has pull request #${jobInfo.pull_request.number}`, CopilotRemoteAgentManager.ID);
1624+
return jobInfo;
1625+
}
1626+
await new Promise(resolve => setTimeout(resolve, pollInterval));
1627+
}
1628+
1629+
Logger.warn(`Timed out waiting for job ${jobId} to have pull request information`, CopilotRemoteAgentManager.ID);
1630+
return undefined;
1631+
}
1632+
15881633
public async cancelMostRecentChatSession(pullRequest: PullRequestModel): Promise<void> {
15891634
const capi = await this.copilotApi;
15901635
if (!capi) {

src/github/pullRequestOverview.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { isCopilotOnMyBehalf, PullRequestModel } from './pullRequestModel';
3737
import { PullRequestView } from './pullRequestOverviewCommon';
3838
import { pickEmail, reviewersQuickPick } from './quickPicks';
3939
import { parseReviewers } from './utils';
40-
import { CancelCodingAgentReply, MergeArguments, MergeResult, PullRequest, ReviewType, SubmitReviewReply } from './views';
40+
import { CancelCodingAgentReply, DeleteReviewResult, MergeArguments, MergeResult, PullRequest, ReviewType, SubmitReviewReply } from './views';
4141

4242
export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestModel> {
4343
public static override ID: string = 'PullRequestOverviewPanel';
@@ -402,6 +402,8 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
402402
return this.cancelCodingAgent(message);
403403
case 'pr.openCommitChanges':
404404
return this.openCommitChanges(message);
405+
case 'pr.delete-review':
406+
return this.deleteReview(message);
405407
}
406408
}
407409

@@ -862,6 +864,17 @@ export class PullRequestOverviewPanel extends IssueOverviewPanel<PullRequestMode
862864
return this._item.deleteReviewComment(comment.id.toString());
863865
}
864866

867+
private async deleteReview(message: IRequestMessage<void>) {
868+
try {
869+
const result: DeleteReviewResult = await this._item.deleteReview();
870+
await this._replyMessage(message, result);
871+
} catch (e) {
872+
Logger.error(formatError(e), PullRequestOverviewPanel.ID);
873+
vscode.window.showErrorMessage(vscode.l10n.t('Deleting review failed. {0}', formatError(e)));
874+
this._throwError(message, `${formatError(e)}`);
875+
}
876+
}
877+
865878
override dispose() {
866879
super.dispose();
867880
disposeAll(this._prListeners);

src/github/views.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { IComment } from '../common/comment';
67
import { CommentEvent, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../common/timelineEvent';
78
import {
89
GithubItemStateEnum,
@@ -138,6 +139,11 @@ export interface MergeResult {
138139
events?: TimelineEvent[];
139140
}
140141

142+
export interface DeleteReviewResult {
143+
deletedReviewId: number;
144+
deletedReviewComments: IComment[];
145+
}
146+
141147
export enum PreReviewState {
142148
None = 0,
143149
Available,

src/test/github/pullRequestGitHelper.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ describe('PullRequestGitHelper', function () {
128128
// Verify that the original local branch is preserved with its commit
129129
const originalBranch = await repository.getBranch('my-branch');
130130
assert.strictEqual(originalBranch.commit, 'local-commit-hash', 'Original branch should be preserved');
131-
131+
132132
// Verify that a unique branch was created and checked out
133133
const uniqueBranch = await repository.getBranch('pr/me/100');
134134
assert.strictEqual(uniqueBranch.commit, 'remote-commit-hash', 'Unique branch should have remote commit');

src/test/issues/stateManager.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@ import { USE_BRANCH_FOR_ISSUES, ISSUES_SETTINGS_NAMESPACE } from '../../common/s
1111

1212
// Mock classes for testing
1313
class MockFolderRepositoryManager {
14-
constructor(public repository: { rootUri: vscode.Uri }) {}
14+
constructor(public repository: { rootUri: vscode.Uri }) { }
1515
}
1616

1717
class MockSingleRepoState {
1818
currentIssue?: MockCurrentIssue;
19-
constructor(public folderManager: MockFolderRepositoryManager) {}
19+
constructor(public folderManager: MockFolderRepositoryManager) { }
2020
}
2121

2222
class MockCurrentIssue {

webviews/common/context.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { CloseResult, OpenCommitChangesArgs } from '../../common/views';
88
import { IComment } from '../../src/common/comment';
99
import { EventType, ReviewEvent, SessionLinkInfo, TimelineEvent } from '../../src/common/timelineEvent';
1010
import { IProjectItem, MergeMethod, ReadyForReview } from '../../src/github/interface';
11-
import { CancelCodingAgentReply, ChangeAssigneesReply, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, SubmitReviewReply } from '../../src/github/views';
11+
import { CancelCodingAgentReply, ChangeAssigneesReply, DeleteReviewResult, MergeArguments, MergeResult, ProjectItemsReply, PullRequest, SubmitReviewReply } from '../../src/github/views';
1212
import { getState, setState, updateState } from './cache';
1313
import { getMessageHandler, MessageHandler } from './message';
1414

@@ -158,6 +158,30 @@ export class PRContext {
158158

159159
public submit = (body: string) => this.submitReviewCommand('pr.submit', body);
160160

161+
public deleteReview = async () => {
162+
try {
163+
const result: DeleteReviewResult = await this.postMessage({ command: 'pr.delete-review' });
164+
165+
const state = this.pr;
166+
const eventsWithoutPendingReview = state?.events.filter(event =>
167+
!(event.event === EventType.Reviewed && event.id === result.deletedReviewId)
168+
) ?? [];
169+
170+
if (state && (eventsWithoutPendingReview.length < state.events.length)) {
171+
// Update the PR state to reflect the deleted review
172+
state.busy = false;
173+
state.pendingCommentText = '';
174+
state.pendingCommentDrafts = {};
175+
// Remove the deleted review from events
176+
state.events = eventsWithoutPendingReview;
177+
this.updatePR(state);
178+
}
179+
return result;
180+
} catch (error) {
181+
return this.updatePR({ busy: false });
182+
}
183+
};
184+
161185
public close = async (body?: string) => {
162186
const { pr } = this;
163187
if (!pr) {

0 commit comments

Comments
 (0)