src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts

484 LOC · 427 covered · 57 uncovered · 110 ranges · 955 concepts · 31 introducers · 498 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- agentHostPullRequestOperationHandler.ts ×16
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { CancellationToken } from '../../../base/common/cancellation.js';
7 > import { URI } from '../../../base/common/uri.js';
8 > import { localize } from '../../../nls.js';
9 > import { IAgentService } from '../common/agentService.js';
10 > import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js';
11 > import { parseChangesetUri } from '../common/changesetUri.js';
12 > import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js';
13 > import { readSessionGitHubState, readSessionGitState, type ChangesetOperationFollowUp, type ISessionFileDiff, type ISessionWithDefaultChat } from '../common/state/sessionState.js';
14 > import { ILogService } from '../../log/common/log.js';
15 > import { IAgentHostGitService } from '../common/agentHostGitService.js';
16 > import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js';
17 > import { type AutoMergeMethod, type CreatedPullRequest, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js';
18 > import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
19 > import { ICopilotApiService, type ICopilotUtilityChatMessage } from './shared/copilotApiService.js';
20 > import { buildConversationContext } from '../common/agentHostConversationContext.js';
21 >
22 > /**
23 > * Soft upper bound, in characters, for the conversation context fed to the
24 > * utility model when generating a PR title and description. Sized to stay
25 > * within the small model's context window while leaving room for the changed
26 > * file summary and prompt scaffolding.
27 > */
28 > const MAX_PR_CONVERSATION_CONTEXT_CHARS = 12_000;
29 >
30 > /**
31 > * Soft upper bound, in characters, for the changed-file summary fed to the
32 > * utility model when generating a PR title and description.
33 > */
34 > const MAX_PR_CHANGE_SUMMARY_CHARS = 4_000;
35 >
36 > export interface PullRequestCreatedEvent {
37 > readonly sessionKey: string;
38 > readonly pullRequestUrl: string;
39 > }
40 >
41 > /**
42 > * Server-side handler for the `create-pr` and `create-draft-pr` changeset
43 > * operations advertised on git-backed sessions whose working directory has
44 > * a GitHub remote. Operation availability is recomputed by
45 > * `AgentHostChangesetOperationService.updateOperations`.
46 > *
47 > * The flow mirrors the Copilot CLI extension's `createPullRequest` helper
48 > * (`extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts`):
49 > *
50 > * 1. Resolve session → working directory + current/base branch from
51 > * {@link ISessionGitState}.
52 > * 2. Commit any uncommitted working-tree changes.
53 > * 3. Push the current branch to `origin` (with `--set-upstream` when missing).
54 > * 4. Resolve `owner` / `repo` from {@link ISessionGitState.githubOwner}
55 > * / {@link ISessionGitState.githubRepo} (populated by the git probe).
56 > * 5. Reuse an existing PR for the branch, or POST `/repos/{owner}/{repo}/pulls`
57 > * via {@link IAgentHostOctoKitService}.
58 > * 6. Return the PR URL as an {@link InvokeChangesetOperationResult.followUp}.
59 > */
60 > export class AgentHostPullRequestOperationHandler implements IChangesetOperationHandler {
61 >
62 > public static readonly OPERATION_CREATE_PR = 'create-pr';
63 > public static readonly OPERATION_CREATE_DRAFT_PR = 'create-draft-pr';
64 > public static readonly OPERATION_CREATE_PR_AUTO_MERGE = 'create-pr-auto-merge';
65 > public static readonly OPERATION_CREATE_PR_AUTO_SQUASH = 'create-pr-auto-squash';
66 > public static readonly OPERATION_CREATE_PR_AUTO_REBASE = 'create-pr-auto-rebase';
67 >
68 > constructor(
69 > private readonly _draft: boolean, agentHostPullRequestOperationHandler.ts ×1
70 > private readonly _autoMergeMethod: AutoMergeMethod | undefined,
71 > private readonly _getSessionState: (sessionKey: string) => ISessionWithDefaultChat | undefined,
72 > private readonly _onPullRequestCreated: (event: PullRequestCreatedEvent) => void,
73 > @IAgentService private readonly _agentService: IAgentService,
74 > @IAgentHostGitService private readonly _gitService: IAgentHostGitService,
75 > @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService,
76 > @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService,
77 > @ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
78 > @ILogService private readonly _logService: ILogService,
79 > ) { }
81 > async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise<InvokeChangesetOperationResult> {
82 > const abortController = new AbortController(); agentHostPullRequestOperationHandler.ts ×13
83 > if (token.isCancellationRequested) {
84 > abortController.abort(); agentHostPullRequestOperationHandler.ts ×2
85 > }
86 > const cancellationListener = token.onCancellationRequested(() => abortController.abort()); agentHostPullRequestOperationHandler.ts ×13
87 > try {
88 > return await this._invoke(params, token, abortController.signal);
89 > } finally {
90 > cancellationListener.dispose();
91 > }
92 > }
94 > private async _invoke(params: InvokeChangesetOperationParams, token: CancellationToken, signal: AbortSignal): Promise<InvokeChangesetOperationResult> {
95 > const parsed = parseChangesetUri(params.channel); agentHostPullRequestOperationHandler.ts ×13
96 > if (!parsed) {
97 throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a changeset URI: ${params.channel}`);
98 }
99 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×13
100 > const sessionUri = parsed.sessionUri;
101 >
102 > const sessionState = this._getSessionState(sessionUri);
103 > if (!sessionState) {
104 throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`);
105 }
107 > const workingDirectoryStr = sessionState.workingDirectories?.[0];
108 > if (!workingDirectoryStr) { agentHostPullRequestOperationHandler.ts ×13
109 throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`);
110 }
112 > const gitHubState = readSessionGitHubState(sessionState._meta);
113 > if (!gitHubState?.owner || !gitHubState?.repo) { agentHostPullRequestOperationHandler.ts ×13
114 throw new ProtocolError(
115 JsonRpcErrorCodes.InternalError,
116 `Session's working directory is not a GitHub-backed git repo: ${sessionUri}`,
117 );
118 }
120 > const workingDirectory = URI.parse(workingDirectoryStr);
121 > const gitState = readSessionGitState(sessionState._meta);
122 > const branchName = gitState?.branchName ?? await this._gitService.getCurrentBranch(workingDirectory); agentHostPullRequestOperationHandler.ts ×13
123 if (!branchName) {
124 throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`);
125 }
127 > const baseBranchName = gitState?.baseBranchName ?? (await this._gitService.getDefaultBranch(workingDirectory))?.name; agentHostPullRequestOperationHandler.ts ×13
128 > if (!baseBranchName) {
129 throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine base branch for ${workingDirectory}`);
130 }
131 > const base = baseBranchName; agentHostPullRequestOperationHandler.ts ×7
132 >
133 > const repoResource = this._gitHubEndpointService.getRepoResource();
134 > const authToken = this._agentService.getAuthToken({
135 > resource: repoResource.resource,
136 > scopes: repoResource.scopes_supported,
137 > });
138 > if (!authToken) {
139 throw new ProtocolError(
140 AHP_AUTH_REQUIRED,
141 localize('agentHost.changeset.pr.authRequired', "Sign in to GitHub with repository access to create a pull request."),
142 [repoResource],
143 );
144 }
146 > const hasUncommitted = await this._gitService.hasUncommittedChanges(workingDirectory);
147 > if (hasUncommitted) {
148 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×3
149 > this._logService.info(`[AgentHostPullRequestOperationHandler] Committing uncommitted changes for session ${sessionUri}`);
150 > try {
151 > await this._gitService.commitAll(workingDirectory, this._formatCommitMessage(branchName));
152 > } catch (err) {
153 this._throwIfCancelled(token);
154 throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to commit changes before creating a pull request: ${err instanceof Error ? err.message : String(err)}`);
155 }
157 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×7
158 >
159 > const branchChanges = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri, baseBranch: base });
160 > if (branchChanges === undefined) {
161 > throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.computeChangesFailed', "Could not compute branch changes to create a pull request.")); agentHostPullRequestOperationHandler.ts ×1
162 > }
163 > if (branchChanges !== undefined && branchChanges.length === 0) { agentHostPullRequestOperationHandler.ts ×13
164 > throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.noChanges', "There are no branch changes to create a pull request for.")); agentHostPullRequestOperationHandler.ts ×1
165 > }
166 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×2
167 >
168 > this._logService.info(`[AgentHostPullRequestOperationHandler] Pushing branch ${branchName} for session ${sessionUri}`);
169 > const upstreamPresent = await this._gitService.hasUpstream(workingDirectory, branchName);
170 > this._throwIfCancelled(token);
171 > try {
172 > await this._gitService.push(workingDirectory, { ref: branchName, setUpstream: !upstreamPresent });
173 > } catch (err) {
174 this._throwIfCancelled(token);
175 throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to push branch '${branchName}': ${err instanceof Error ? err.message : String(err)}`);
176 }
177 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×2
178 >
179 > const existing = await this._octoKitService.findPullRequestByHeadBranch(gitHubState.owner, gitHubState.repo, branchName, authToken, signal);
180 > if (existing) {
181 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×1
182 > return await this._finalize(existing, true, sessionUri, gitHubState.owner, gitHubState.repo, authToken, signal, token);
183 > }
184 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×5
185 >
186 > const generated = await this._generateTitleAndDescription(sessionState, branchName, base, branchChanges, signal, token);
187 > this._throwIfCancelled(token);
188 > const title = generated?.title ?? this._formatTitle(branchName); agentHostPullRequestOperationHandler.ts ×13
189 > const body = generated?.description ?? this._formatBody(branchName, base);
190 >
191 > this._logService.info(`[AgentHostPullRequestOperationHandler] Creating ${this._draft ? 'draft ' : ''}PR ${gitHubState.owner}/${gitHubState.repo} ${branchName} -> ${base}`);
192 > let created: CreatedPullRequest;
193 > try {
194 > created = await this._octoKitService.createPullRequest(
195 > gitHubState.owner,
196 > gitHubState.repo,
197 > title,
198 > body,
199 > branchName,
200 > base,
201 > this._draft,
202 > authToken,
203 > signal,
204 > );
206 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×1
207 > let foundAfterFailure: CreatedPullRequest | undefined;
208 > try {
209 > foundAfterFailure = await this._octoKitService.findPullRequestByHeadBranch(gitHubState.owner, gitHubState.repo, branchName, authToken, signal);
210 > } catch {
211 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×1
212 > throw err;
213 > }
214 > if (foundAfterFailure) { agentHostPullRequestOperationHandler.ts ×1
215 > this._throwIfCancelled(token);
216 > return await this._finalize(foundAfterFailure, true, sessionUri, gitHubState.owner, gitHubState.repo, authToken, signal, token);
217 > }
218 throw err;
219 }
220 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×2
221 > return await this._finalize(created, false, sessionUri, gitHubState.owner, gitHubState.repo, authToken, signal, token);
224 > /**
225 > * Notifies listeners that the pull request now exists, optionally enables
226 > * auto-merge with the configured {@link AutoMergeMethod} (best-effort: a
227 > * failure to enable auto-merge does not fail the operation), and builds the
228 > * result message describing what happened.
229 > */
230 > private async _finalize(
231 > pr: CreatedPullRequest, agentHostPullRequestOperationHandler.ts ×10
232 > isExisting: boolean,
233 > sessionUri: string,
234 > owner: string,
235 > repo: string,
236 > authToken: string,
237 > signal: AbortSignal,
238 > token: CancellationToken,
239 > ): Promise<InvokeChangesetOperationResult> {
240 > if (!this._autoMergeMethod) {
241 > // No auto-merge configured agentHostPullRequestOperationHandler.ts ×1
242 > this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: pr.url });
243 > return this._createResult(pr, this._buildMessage(pr, isExisting, 'none', undefined));
244 > }
246 > let autoMergeError: string | undefined;
247 > let autoMergeOutcome: 'none' | 'enabled' | 'failed' = 'none';
248 >
249 > if (pr.nodeId) {
251 > await this._octoKitService.enablePullRequestAutoMerge(pr.nodeId, this._autoMergeMethod, authToken, signal);
252 > autoMergeOutcome = 'enabled'; agentHostPullRequestOperationHandler.ts ×3
254 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×1
255 > autoMergeError = err instanceof Error ? err.message : String(err);
256 > autoMergeOutcome = 'failed';
257 > this._logService.warn(`[AgentHostPullRequestOperationHandler] Failed to enable auto-merge for ${owner}/${repo}#${pr.number}: ${autoMergeError}`);
258 > }
260 > autoMergeError = localize('agentHost.changeset.pr.autoMerge.noNodeId', "the pull request identifier was not returned by GitHub."); agentHostPullRequestOperationHandler.ts ×2
261 > autoMergeOutcome = 'failed';
262 > this._logService.warn(`[AgentHostPullRequestOperationHandler] Cannot enable auto-merge for ${owner}/${repo}#${pr.number}: missing pull request node id`);
263 > }
265 > this._onPullRequestCreated({ sessionKey: sessionUri, pullRequestUrl: pr.url });
266 > return this._createResult(pr, this._buildMessage(pr, isExisting, autoMergeOutcome, autoMergeError));
269 > private _buildMessage(pr: CreatedPullRequest, isExisting: boolean, autoMergeOutcome: 'none' | 'enabled' | 'failed', autoMergeError: string | undefined): string {
270 > let mergeMethodLabel: string | undefined; agentHostPullRequestOperationHandler.ts ×10
271 > switch (this._autoMergeMethod) {
272 > case 'SQUASH':
273 > mergeMethodLabel = localize('agentHost.changeset.pr.autoMerge.squash', "squash"); agentHostPullRequestOperationHandler.ts ×3
274 > break;
276 > mergeMethodLabel = localize('agentHost.changeset.pr.autoMerge.rebase', "rebase"); agentHostPullRequestOperationHandler.ts ×2
277 > break;
279 > mergeMethodLabel = localize('agentHost.changeset.pr.autoMerge.merge', "merge"); agentHostPullRequestOperationHandler.ts ×1
280 > break;
282 >
283 > if (isExisting) {
284 > switch (autoMergeOutcome) { agentHostPullRequestOperationHandler.ts ×3
285 > case 'enabled':
286 return localize('agentHost.changeset.pr.existing.autoMerge', "Pull request [#{0}]({1}) already exists; enabled auto-merge ({2}).", pr.number, pr.url, mergeMethodLabel);
288 return localize('agentHost.changeset.pr.existing.autoMergeFailed', "Pull request [#{0}]({1}) already exists, but auto-merge could not be enabled: {2}", pr.number, pr.url, autoMergeError ?? '');
290 > return localize('agentHost.changeset.pr.existing', "Pull request [#{0}]({1}) already exists.", pr.number, pr.url);
291 > }
292 > }
294 > switch (autoMergeOutcome) {
295 > case 'enabled':
296 > return localize('agentHost.changeset.pr.created.autoMerge', "Created pull request [#{0}]({1}) with auto-merge ({2}) enabled.", pr.number, pr.url, mergeMethodLabel); agentHostPullRequestOperationHandler.ts ×3
298 > return localize('agentHost.changeset.pr.created.autoMergeFailed', "Created pull request [#{0}]({1}), but auto-merge could not be enabled: {2}", pr.number, pr.url, autoMergeError ?? ''); agentHostPullRequestOperationHandler.ts ×1
301 ? localize('agentHost.changeset.pr.createdDraft', "Created draft pull request [#{0}]({1}).", pr.number, pr.url)
302 > : localize('agentHost.changeset.pr.created', "Created pull request [#{0}]({1}).", pr.number, pr.url); agentHostPullRequestOperationHandler.ts ×2
304 > }
306 > private _throwIfCancelled(token: CancellationToken): void {
307 > if (token.isCancellationRequested) { agentHostPullRequestOperationHandler.ts ×13
308 > throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.pr.cancelled', "Pull request operation was cancelled.")); agentHostPullRequestOperationHandler.ts ×2
309 > }
312 > private _formatTitle(branchName: string): string {
313 > // Beautify a branch name like `feat/foo-bar` into `feat: foo bar`. agentHostPullRequestOperationHandler.ts ×3
314 > const idx = branchName.indexOf('/');
315 > if (idx > 0 && idx < branchName.length - 1) {
316 > const prefix = branchName.substring(0, idx);
317 > const rest = branchName.substring(idx + 1).replace(/[-_]+/g, ' ');
318 > return `${prefix}: ${rest}`;
319 > }
320 return branchName.replace(/[-_]+/g, ' ');
323 > private _formatCommitMessage(branchName: string): string {
324 > return localize('agentHost.changeset.pr.commitMessage', "Agent Host changes for {0}", branchName); agentHostPullRequestOperationHandler.ts ×3
325 > }
327 > private _formatBody(branchName: string, baseBranchName: string): string {
328 > return localize('agentHost.changeset.pr.body', "Created from `{0}` targeting `{1}`.", branchName, baseBranchName); agentHostPullRequestOperationHandler.ts ×3
329 > }
331 > /**
332 > * Best-effort generation of a PR title and description using the utility
333 > * model. The model is given the main session conversation (only the
334 > * markdown text of user requests and agent responses — tool calls,
335 > * subagents, and reasoning are excluded and the text is character-bounded)
336 > * along with a summary of the changed files. Returns `undefined` when no
337 > * Copilot token is available or generation fails, so the caller can fall
338 > * back to the branch-name based title/description. PR creation must never
339 > * fail just because the model is unavailable.
340 > */
341 > private async _generateTitleAndDescription(
342 > sessionState: ISessionWithDefaultChat, agentHostPullRequestOperationHandler.ts ×5
343 > branchName: string,
344 > base: string,
345 > branchChanges: readonly ISessionFileDiff[],
346 > signal: AbortSignal,
347 > token: CancellationToken,
348 > ): Promise<{ title: string; description: string } | undefined> {
349 > const copilotResource = this._gitHubEndpointService.getCopilotResource();
350 > const copilotToken = this._agentService.getAuthToken({
351 > resource: copilotResource.resource,
352 > scopes: copilotResource.scopes_supported,
353 > });
354 > if (!copilotToken) {
356 > }
358 > const conversation = buildConversationContext(sessionState.turns, { maxChars: MAX_PR_CONVERSATION_CONTEXT_CHARS });
359 > const changeSummary = this._summarizeDiffsForPrompt(branchChanges);
360 > if (!conversation && !changeSummary) { agentHostPullRequestOperationHandler.ts ×5
361 return undefined;
362 }
364 > try {
365 > const raw = await this._copilotApiService.utilityChatCompletion(copilotToken, {
366 > messages: this._buildTitleAndDescriptionPrompt(branchName, base, conversation, changeSummary),
367 > }, { signal });
368 > this._throwIfCancelled(token); agentHostPullRequestOperationHandler.ts ×8
369 > return this._parseTitleAndDescription(raw);
371 > if (token.isCancellationRequested) { agentHostPullRequestOperationHandler.ts ×2
372 return undefined;
373 }
374 > this._logService.warn(`[AgentHostPullRequestOperationHandler] Failed to generate PR title and description: ${err instanceof Error ? err.message : String(err)}`); agentHostPullRequestOperationHandler.ts ×2
375 > return undefined;
376 > }
379 > private _buildTitleAndDescriptionPrompt(branchName: string, base: string, conversation: string | undefined, changeSummary: string): ICopilotUtilityChatMessage[] {
380 > const userSections: string[] = [ agentHostPullRequestOperationHandler.ts ×10
381 > `Branch: ${branchName}`,
382 > `Base branch: ${base}`,
383 > ];
384 > if (changeSummary) {
385 > userSections.push(`Changed files:\n${changeSummary}`);
386 > }
387 > if (conversation) {
388 > userSections.push(`Conversation (the request that produced these changes):\n${conversation}`); agentHostPullRequestOperationHandler.ts ×8
389 > }
391 > {
392 > role: 'system',
393 > content: [
394 > 'You write clear, concise GitHub pull request titles and descriptions.',
395 > 'The first line of your reply is the PR title: a short imperative summary under 72 characters, with no "Title:" prefix, no surrounding quotes, and no markdown heading.',
396 > 'After the title, add one blank line, then write the PR description in GitHub-flavored markdown.',
397 > 'Summarize what changed and why, grounded in the conversation and changed files. Use a short paragraph and/or bullet points.',
398 > 'Do not invent changes that are not supported by the provided context, and do not wrap the whole reply in code fences.',
399 > ].join(' '),
400 > },
401 > {
402 > role: 'user',
403 > content: userSections.join('\n\n'),
404 > },
405 > ];
406 > }
408 > private _summarizeDiffsForPrompt(diffs: readonly ISessionFileDiff[]): string {
409 > const lines: string[] = []; agentHostPullRequestOperationHandler.ts ×10
410 > let length = 0;
411 > for (const diff of diffs) {
412 > const before = diff.before?.uri;
413 > const after = diff.after?.uri;
414 > const path = after ?? before ?? '(unknown)';
415 > let kind = 'Edit';
416 > if (!before && after) {
417 > kind = 'Create';
418 > } else if (before && !after) {
419 kind = 'Delete';
420 } else if (before && after && before !== after) {
421 kind = 'Rename';
422 }
423 > const line = `- ${kind}: ${this._displayUri(path)} (+${diff.diff?.added ?? 0} -${diff.diff?.removed ?? 0})`; agentHostPullRequestOperationHandler.ts ×10
424 > lines.push(line);
425 > // `+ 1` accounts for the newline that joins this line to the previous one.
426 > length += line.length + (lines.length > 1 ? 1 : 0);
427 > if (length > MAX_PR_CHANGE_SUMMARY_CHARS) {
428 lines.push('[file list truncated]');
429 break;
430 }
432 > return lines.join('\n');
433 > }
435 > private _displayUri(uri: string): string {
437 > const parsed = URI.parse(uri);
438 > return parsed.scheme === 'file' ? parsed.fsPath : parsed.path || uri;
439 > } catch {
440 return uri;
441 }
444 > private _parseTitleAndDescription(raw: string): { title: string; description: string } | undefined {
445 > let text = raw.trim().replace(/\r\n/g, '\n'); agentHostPullRequestOperationHandler.ts ×8
446 > const fenced = /^```(?:markdown|md|text)?\s*([\s\S]*?)\s*```$/i.exec(text);
447 > if (fenced) {
448 text = fenced[1].trim();
449 }
451 return undefined;
452 }
454 > const lines = text.split('\n');
455 > let i = 0;
456 > while (i < lines.length && lines[i].trim().length === 0) {
457 i++;
458 }
459 > if (i >= lines.length) { agentHostPullRequestOperationHandler.ts ×8
460 return undefined;
461 }
463 > const title = lines[i].trim()
464 > .replace(/^#+\s*/, '')
465 > .replace(/^title:\s*/i, '')
466 > .trim()
467 > .replace(/^"(?<inner>.+)"$/, (_match, inner) => inner)
468 > .trim();
469 > if (!title) {
470 return undefined;
471 }
473 > const description = lines.slice(i + 1).join('\n').trim().replace(/^description:\s*/i, '').trim();
474 > return { title, description };
475 > }
477 > private _createResult(created: { readonly url: string; readonly number: number }, message: string): InvokeChangesetOperationResult {
478 > const followUp: ChangesetOperationFollowUp = { agentHostPullRequestOperationHandler.ts ×10
479 > content: { uri: created.url, contentType: 'text/html' },
480 > external: true,
481 > };
482 > return { message: { markdown: message }, followUp };
483 > }