mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-21 10:01:35 +01:00
217 lines
8.8 KiB
TypeScript
217 lines
8.8 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { basename } from '../../../base/common/resources.js';
|
|
import { CancellationToken } from '../../../base/common/cancellation.js';
|
|
import { URI } from '../../../base/common/uri.js';
|
|
import { localize } from '../../../nls.js';
|
|
import { IAgentService } from '../common/agentService.js';
|
|
import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js';
|
|
import { parseChangesetUri } from '../common/changesetUri.js';
|
|
import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js';
|
|
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
|
|
import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js';
|
|
import { readSessionGitState, type ISessionFileDiff, type SessionState } from '../common/state/sessionState.js';
|
|
import { ILogService } from '../../log/common/log.js';
|
|
import { IAgentHostGitService } from '../common/agentHostGitService.js';
|
|
import { CopilotApiError, ICopilotApiService } from './shared/copilotApiService.js';
|
|
|
|
const MAX_CHANGE_SUMMARY_PROMPT_CHARS = 20_000;
|
|
|
|
export class AgentHostCommitOperationHandler implements IChangesetOperationHandler {
|
|
|
|
public static readonly OPERATION_COMMIT = 'commit';
|
|
|
|
constructor(
|
|
private readonly _getSessionState: (sessionKey: string) => SessionState | undefined,
|
|
private readonly _onCommitted: (sessionKey: string) => Promise<void>,
|
|
@IAgentService private readonly _agentService: IAgentService,
|
|
@IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService,
|
|
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
|
|
@ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
|
|
@ILogService private readonly _logService: ILogService,
|
|
) { }
|
|
|
|
async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise<InvokeChangesetOperationResult> {
|
|
const abortController = new AbortController();
|
|
if (token.isCancellationRequested) {
|
|
abortController.abort();
|
|
}
|
|
const cancellationListener = token.onCancellationRequested(() => abortController.abort());
|
|
try {
|
|
return await this._invoke(params, token, abortController.signal);
|
|
} finally {
|
|
cancellationListener.dispose();
|
|
}
|
|
}
|
|
|
|
private async _invoke(params: InvokeChangesetOperationParams, token: CancellationToken, signal: AbortSignal): Promise<InvokeChangesetOperationResult> {
|
|
const parsed = parseChangesetUri(params.channel);
|
|
if (!parsed) {
|
|
throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not an uncommitted changeset URI: ${params.channel}`);
|
|
}
|
|
this._throwIfCancelled(token);
|
|
|
|
const sessionUri = parsed.sessionUri;
|
|
const sessionState = this._getSessionState(sessionUri);
|
|
if (!sessionState) {
|
|
throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`);
|
|
}
|
|
|
|
const workingDirectoryStr = sessionState.workingDirectory;
|
|
if (!workingDirectoryStr) {
|
|
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`);
|
|
}
|
|
const workingDirectory = URI.parse(workingDirectoryStr);
|
|
|
|
const gitState = readSessionGitState(sessionState._meta);
|
|
if (!gitState) {
|
|
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session's working directory is not a git repo: ${sessionUri}`);
|
|
}
|
|
|
|
const hasUncommitted = await this._gitService.hasUncommittedChanges(workingDirectory);
|
|
if (!hasUncommitted) {
|
|
return { message: { markdown: localize('agentHost.changeset.commit.noChanges', "No uncommitted changes to commit.") } };
|
|
}
|
|
this._throwIfCancelled(token);
|
|
|
|
const copilotResource = this._gitHubEndpointService.getCopilotResource();
|
|
const authToken = this._agentService.getAuthToken({
|
|
resource: copilotResource.resource,
|
|
scopes: copilotResource.scopes_supported,
|
|
});
|
|
if (!authToken) {
|
|
throw new ProtocolError(
|
|
AHP_AUTH_REQUIRED,
|
|
localize('agentHost.changeset.commit.authRequired', "Sign in to GitHub Copilot to generate a commit message."),
|
|
[copilotResource],
|
|
);
|
|
}
|
|
|
|
const diffs = await this._gitService.computeSessionFileDiffs(workingDirectory, { sessionUri });
|
|
if (!diffs || diffs.length === 0) {
|
|
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.commit.diffFailed', "Could not compute uncommitted changes to generate a commit message."));
|
|
}
|
|
this._throwIfCancelled(token);
|
|
|
|
let message: string;
|
|
try {
|
|
message = this._cleanCommitMessage(await this._copilotApiService.utilityChatCompletion(authToken, {
|
|
messages: this._buildCommitMessagePrompt(workingDirectory, gitState.branchName, diffs),
|
|
}, { signal }));
|
|
} catch (err) {
|
|
this._throwIfCancelled(token);
|
|
if (this._isAuthFailure(err)) {
|
|
throw new ProtocolError(
|
|
AHP_AUTH_REQUIRED,
|
|
localize('agentHost.changeset.commit.authExpired', "Authentication is required to generate a commit message. Please sign in to GitHub Copilot and try again."),
|
|
[copilotResource],
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
if (!message) {
|
|
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.commit.emptyMessage', "Generated commit message was empty."));
|
|
}
|
|
this._throwIfCancelled(token);
|
|
|
|
this._logService.info(`[AgentHostCommitOperationHandler] Committing uncommitted changes for session ${sessionUri}`);
|
|
try {
|
|
await this._gitService.commitAll(workingDirectory, message);
|
|
} catch (err) {
|
|
this._throwIfCancelled(token);
|
|
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Failed to commit changes: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
try {
|
|
await this._onCommitted(sessionUri);
|
|
} catch (err) {
|
|
this._logService.warn(`[AgentHostCommitOperationHandler] Post-commit refresh failed for session ${sessionUri}: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
return { message: { markdown: localize('agentHost.changeset.commit.committed', "Committed changes with message: `{0}`", message.split('\n')[0]) } };
|
|
}
|
|
|
|
private _buildCommitMessagePrompt(workingDirectory: URI, branchName: string | undefined, diffs: readonly ISessionFileDiff[]): { role: 'system' | 'user'; content: string }[] {
|
|
const changeSummary = this._summarizeDiffsForPrompt(diffs);
|
|
return [
|
|
{
|
|
role: 'system',
|
|
content: [
|
|
'You generate concise Git commit messages.',
|
|
'Return only the commit message text, with no markdown or code fences.',
|
|
'Use imperative mood. Keep the subject line under 72 characters.',
|
|
'Add a body only when it helps explain multiple related changes.',
|
|
].join(' '),
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
`Repository: ${basename(workingDirectory)}`,
|
|
`Branch: ${branchName ?? 'unknown'}`,
|
|
'Changed files:',
|
|
changeSummary,
|
|
].join('\n'),
|
|
},
|
|
];
|
|
}
|
|
|
|
private _summarizeDiffsForPrompt(diffs: readonly ISessionFileDiff[]): string {
|
|
const lines: string[] = [];
|
|
for (const diff of diffs) {
|
|
const before = diff.before?.uri;
|
|
const after = diff.after?.uri;
|
|
const path = after ?? before ?? '(unknown)';
|
|
let kind = 'Edit';
|
|
if (!before && after) {
|
|
kind = 'Create';
|
|
} else if (before && !after) {
|
|
kind = 'Delete';
|
|
} else if (before && after && before !== after) {
|
|
kind = 'Rename';
|
|
}
|
|
lines.push(`- ${kind}: ${this._displayUri(path)} (+${diff.diff?.added ?? 0} -${diff.diff?.removed ?? 0})`);
|
|
if (lines.join('\n').length > MAX_CHANGE_SUMMARY_PROMPT_CHARS) {
|
|
lines.push('[file list truncated]');
|
|
break;
|
|
}
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
private _displayUri(uri: string): string {
|
|
try {
|
|
const parsed = URI.parse(uri);
|
|
return parsed.scheme === 'file' ? parsed.fsPath : parsed.path || uri;
|
|
} catch {
|
|
return uri;
|
|
}
|
|
}
|
|
|
|
private _cleanCommitMessage(raw: string): string {
|
|
let text = raw.trim().replace(/\r\n/g, '\n');
|
|
const fenced = /^```(?:text|gitcommit)?\s*([\s\S]*?)\s*```$/i.exec(text);
|
|
if (fenced) {
|
|
text = fenced[1].trim();
|
|
}
|
|
return text;
|
|
}
|
|
|
|
private _isAuthFailure(err: unknown): boolean {
|
|
if (err instanceof CopilotApiError) {
|
|
return err.status === 401 || err.status === 403;
|
|
}
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return /\b(401|403)\b/.test(message)
|
|
&& /\b(auth|authorization|unauthorized|forbidden|token|copilot endpoint discovery|copilot session token mint)\b/i.test(message);
|
|
}
|
|
|
|
private _throwIfCancelled(token: CancellationToken): void {
|
|
if (token.isCancellationRequested) {
|
|
throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.commit.cancelled', "Commit operation was cancelled."));
|
|
}
|
|
}
|
|
}
|