Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions documentation/IssueFeatures.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
We've added some experimental GitHub issue features.

# Code actions
# Code actions and CodeLens

Wherever there is a `TODO` comment in your code, the **Create Issue from Comment** code action will show. This takes your text selection, and creates a GitHub issue with the selection as a permalink in the issue body. It also inserts the issue number after the `TODO`.
Wherever there is a `TODO` comment in your code, two actions are available:

1. **CodeLens**: Clickable actions appear directly above the TODO comment line for quick access
2. **Code actions**: The same actions are available via the lightbulb quick fix menu

Both provide two options:
- **Create Issue from Comment**: Takes your text selection and creates a GitHub issue with the selection as a permalink in the issue body. It also inserts the issue number after the `TODO`.
- **Delegate to coding agent**: Starts a Copilot coding agent session to work on the TODO task (when available)

![Create Issue from Comment](images/createIssueFromComment.gif)

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,11 @@
],
"description": "%githubIssues.createIssueTriggers.description%"
},
"githubPullRequests.codingAgent.codeLens": {
"type": "boolean",
"default": true,
"description": "%githubPullRequests.codingAgent.codeLens.description%"
},
"githubIssues.createInsertFormat": {
"type": "string",
"enum": [
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"githubIssues.ignoreMilestones.description": "An array of milestones titles to never show issues from.",
"githubIssues.createIssueTriggers.description": "Strings that will cause the 'Create issue from comment' code action to show.",
"githubIssues.createIssueTriggers.items": "String that enables the 'Create issue from comment' code action. Should not contain whitespace.",
"githubPullRequests.codingAgent.codeLens.description": "Show CodeLens actions above TODO comments for delegating to coding agent.",
"githubIssues.createInsertFormat.description": "Controls whether an issue number (ex. #1234) or a full url (ex. https://github.com/owner/name/issues/1234) is inserted when the Create Issue code action is run.",
"githubIssues.issueCompletions.enabled.description": "Controls whether completion suggestions are shown for issues.",
"githubIssues.userCompletions.enabled.description": "Controls whether completion suggestions are shown for users.",
Expand Down
11 changes: 9 additions & 2 deletions src/@types/vscode.proposed.chatParticipantAdditions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ declare module 'vscode' {
isConfirmed?: boolean;
isComplete?: boolean;
toolSpecificData?: ChatTerminalToolInvocationData;
fromSubAgent?: boolean;

constructor(toolName: string, toolCallId: string, isError?: boolean);
}
Expand Down Expand Up @@ -646,7 +647,13 @@ declare module 'vscode' {
}

export interface ChatRequest {
modeInstructions?: string;
modeInstructionsToolReferences?: readonly ChatLanguageModelToolReference[];
readonly modeInstructions?: string;
readonly modeInstructions2?: ChatRequestModeInstructions;
}

export interface ChatRequestModeInstructions {
readonly content: string;
readonly toolReferences?: readonly ChatLanguageModelToolReference[];
readonly metadata?: Record<string, boolean | string | number>;
}
}
9 changes: 8 additions & 1 deletion src/@types/vscode.proposed.chatParticipantPrivate.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ declare module 'vscode' {

isQuotaExceeded?: boolean;

isRateLimited?: boolean;

level?: ChatErrorLevel;

code?: string;
Expand Down Expand Up @@ -219,6 +221,10 @@ declare module 'vscode' {
chatSessionId?: string;
chatInteractionId?: string;
terminalCommand?: string;
/**
* Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups
*/
fromSubAgent?: boolean;
}

export interface LanguageModelToolInvocationPrepareOptions<T> {
Expand All @@ -233,12 +239,13 @@ declare module 'vscode' {

export interface PreparedToolInvocation {
pastTenseMessage?: string | MarkdownString;
presentation?: 'hidden' | undefined;
presentation?: 'hidden' | 'hiddenAfterComplete' | undefined;
}

export class ExtendedLanguageModelToolResult extends LanguageModelToolResult {
toolResultMessage?: string | MarkdownString;
toolResultDetails?: Array<Uri | Location>;
toolMetadata?: unknown;
}

// #region Chat participant detection
Expand Down
3 changes: 2 additions & 1 deletion src/common/settingKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,5 @@ export const COLOR_THEME = 'colorTheme';
export const CODING_AGENT = `${PR_SETTINGS_NAMESPACE}.codingAgent`;
export const CODING_AGENT_ENABLED = 'enabled';
export const CODING_AGENT_AUTO_COMMIT_AND_PUSH = 'autoCommitAndPush';
export const CODING_AGENT_PROMPT_FOR_CONFIRMATION = 'promptForConfirmation';
export const CODING_AGENT_PROMPT_FOR_CONFIRMATION = 'promptForConfirmation';
export const SHOW_CODE_LENS = 'codeLens';
6 changes: 6 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1005,3 +1005,9 @@ export function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

export function truncate(value: string, maxLength: number, suffix = '...'): string {
if (value.length <= maxLength) {
return value;
}
return `${value.substr(0, maxLength)}${suffix}`;
}
22 changes: 16 additions & 6 deletions src/github/copilotRemoteAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,8 @@ export class CopilotRemoteAgentManager extends Disposable {
status: CopilotPRStatus;
}[]> | undefined;

private _isAssignable: boolean | undefined;

constructor(
private credentialStore: CredentialStore,
public repositoriesManager: RepositoriesManager,
Expand Down Expand Up @@ -348,9 +350,18 @@ export class CopilotRemoteAgentManager extends Disposable {
}

async isAssignable(): Promise<boolean> {
const setCachedResult = (b: boolean) => {
this._isAssignable = b;
return b;
};

if (this._isAssignable !== undefined) {
return this._isAssignable;
}

const repoInfo = await this.repoInfo();
if (!repoInfo) {
return false;
return setCachedResult(false);
}

const { fm } = repoInfo;
Expand All @@ -361,14 +372,12 @@ export class CopilotRemoteAgentManager extends Disposable {
const allAssignableUsers = fm.getAllAssignableUsers();

if (!allAssignableUsers) {
return false;
return setCachedResult(false);
}

// Check if any of the copilot logins are in the assignable users
return allAssignableUsers.some(user => COPILOT_LOGINS.includes(user.login));
return setCachedResult(allAssignableUsers.some(user => COPILOT_LOGINS.includes(user.login)));
} catch (error) {
// If there's an error fetching assignable users, assume not assignable
return false;
return setCachedResult(false);
}
}

Expand Down Expand Up @@ -398,6 +407,7 @@ export class CopilotRemoteAgentManager extends Disposable {

private async updateAssignabilityContext(): Promise<void> {
try {
this._isAssignable = undefined; // Invalidate cache
const available = await this.isAvailable();
commands.setContext('copilotCodingAgentAssignable', available);
} catch (error) {
Expand Down
38 changes: 21 additions & 17 deletions src/issues/issueFeatureRegistrar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
pushAndCreatePR,
USER_EXPRESSION,
} from './util';
import { truncate } from '../common/utils';
import { OctokitCommon } from '../github/common';
import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent';
import { FolderRepositoryManager, PullRequestDefaults } from '../github/folderRepositoryManager';
Expand Down Expand Up @@ -147,8 +148,8 @@ export class IssueFeatureRegistrar extends Disposable {
'issue.startCodingAgentFromTodo',
(todoInfo?: { document: vscode.TextDocument; lineNumber: number; line: string; insertIndex: number; range: vscode.Range }) => {
/* __GDPR__
"issue.startCodingAgentFromTodo" : {}
*/
"issue.startCodingAgentFromTodo" : {}
*/
this.telemetry.sendTelemetryEvent('issue.startCodingAgentFromTodo');
return this.startCodingAgentFromTodo(todoInfo);
},
Expand Down Expand Up @@ -575,8 +576,12 @@ export class IssueFeatureRegistrar extends Disposable {
this._register(
vscode.languages.registerHoverProvider('*', new UserHoverProvider(this.manager, this.telemetry)),
);
const todoProvider = new IssueTodoProvider(this.context, this.copilotRemoteAgentManager);
this._register(
vscode.languages.registerCodeActionsProvider('*', todoProvider, { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }),
);
this._register(
vscode.languages.registerCodeActionsProvider('*', new IssueTodoProvider(this.context, this.copilotRemoteAgentManager), { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }),
vscode.languages.registerCodeLensProvider('*', todoProvider),
);
});
}
Expand Down Expand Up @@ -1488,28 +1493,27 @@ ${options?.body ?? ''}\n
}

const { document, line, insertIndex } = todoInfo;

// Extract the TODO text after the trigger word
const todoText = line.substring(insertIndex).trim();

if (!todoText) {
vscode.window.showWarningMessage(vscode.l10n.t('No task description found in TODO comment'));
return;
}

// Create a prompt for the coding agent
const relativePath = vscode.workspace.asRelativePath(document.uri);
const prompt = vscode.l10n.t('Work on TODO: {0} (from {1})', todoText, relativePath);

// Start the coding agent session
try {
await this.copilotRemoteAgentManager.commandImpl({
userPrompt: prompt,
source: 'todo'
});
} catch (error) {
vscode.window.showErrorMessage(vscode.l10n.t('Failed to start coding agent session: {0}', error.message));
}
return vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: vscode.l10n.t('Delegating \'{0}\' to coding agent', truncate(todoText, 20))
}, async (_progress) => {
try {
await this.copilotRemoteAgentManager.commandImpl({
userPrompt: prompt,
source: 'todo'
});
} catch (error) {
vscode.window.showErrorMessage(vscode.l10n.t('Failed to start coding agent session: {0}', error.message));
}
});
}

async assignToCodingAgent(issueModel: any) {
Expand Down
116 changes: 80 additions & 36 deletions src/issues/issueTodoProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@

import * as vscode from 'vscode';
import { MAX_LINE_LENGTH } from './util';
import { CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE } from '../common/settingKeys';
import { CODING_AGENT, CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE, SHOW_CODE_LENS } from '../common/settingKeys';
import { escapeRegExp } from '../common/utils';
import { CopilotRemoteAgentManager } from '../github/copilotRemoteAgent';
import { ISSUE_OR_URL_EXPRESSION } from '../github/utils';

export class IssueTodoProvider implements vscode.CodeActionProvider {
export class IssueTodoProvider implements vscode.CodeActionProvider, vscode.CodeLensProvider {
private expression: RegExp | undefined;

constructor(
Expand All @@ -30,6 +30,24 @@ export class IssueTodoProvider implements vscode.CodeActionProvider {
this.expression = triggers.length > 0 ? new RegExp(triggers.map(trigger => escapeRegExp(trigger)).join('|')) : undefined;
}

private findTodoInLine(line: string): { match: RegExpMatchArray; search: number; insertIndex: number } | undefined {
const truncatedLine = line.substring(0, MAX_LINE_LENGTH);
const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION);
if (matches) {
return undefined;
}
const match = truncatedLine.match(this.expression!);
const search = match?.index ?? -1;
if (search >= 0 && match) {
const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/);
const insertIndex =
search +
(indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression!)![0].length);
return { match, search, insertIndex };
}
return undefined;
}

async provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
Expand All @@ -43,48 +61,74 @@ export class IssueTodoProvider implements vscode.CodeActionProvider {
let lineNumber = range.start.line;
do {
const line = document.lineAt(lineNumber).text;
const truncatedLine = line.substring(0, MAX_LINE_LENGTH);
const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION);
if (!matches) {
const match = truncatedLine.match(this.expression);
const search = match?.index ?? -1;
if (search >= 0 && match) {
// Create GitHub Issue action
const createIssueAction: vscode.CodeAction = new vscode.CodeAction(
vscode.l10n.t('Create GitHub Issue'),
const todoInfo = this.findTodoInLine(line);
if (todoInfo) {
const { match, search, insertIndex } = todoInfo;
// Create GitHub Issue action
const createIssueAction: vscode.CodeAction = new vscode.CodeAction(
vscode.l10n.t('Create GitHub Issue'),
vscode.CodeActionKind.QuickFix,
);
createIssueAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
createIssueAction.command = {
title: vscode.l10n.t('Create GitHub Issue'),
command: 'issue.createIssueFromSelection',
arguments: [{ document, lineNumber, line, insertIndex, range }],
};
codeActions.push(createIssueAction);

// Start Coding Agent Session action (if copilot manager is available)
if (this.copilotRemoteAgentManager) {
const startAgentAction: vscode.CodeAction = new vscode.CodeAction(
vscode.l10n.t('Delegate to coding agent'),
vscode.CodeActionKind.QuickFix,
);
createIssueAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/);
const insertIndex =
search +
(indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression)![0].length);
createIssueAction.command = {
title: vscode.l10n.t('Create GitHub Issue'),
command: 'issue.createIssueFromSelection',
startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
startAgentAction.command = {
title: vscode.l10n.t('Delegate to coding agent'),
command: 'issue.startCodingAgentFromTodo',
arguments: [{ document, lineNumber, line, insertIndex, range }],
};
codeActions.push(createIssueAction);

// Start Coding Agent Session action (if copilot manager is available)
if (this.copilotRemoteAgentManager) {
const startAgentAction: vscode.CodeAction = new vscode.CodeAction(
vscode.l10n.t('Delegate to coding agent'),
vscode.CodeActionKind.QuickFix,
);
startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
startAgentAction.command = {
title: vscode.l10n.t('Delegate to coding agent'),
command: 'issue.startCodingAgentFromTodo',
arguments: [{ document, lineNumber, line, insertIndex, range }],
};
codeActions.push(startAgentAction);
}
break;
codeActions.push(startAgentAction);
}
break;
}
lineNumber++;
} while (range.end.line >= lineNumber);
return codeActions;
}

async provideCodeLenses(
document: vscode.TextDocument,
_token: vscode.CancellationToken,
): Promise<vscode.CodeLens[]> {
if (this.expression === undefined) {
return [];
}

// Check if CodeLens is enabled
const isCodeLensEnabled = vscode.workspace.getConfiguration(CODING_AGENT).get(SHOW_CODE_LENS, true);
if (!isCodeLensEnabled) {
return [];
}

const codeLenses: vscode.CodeLens[] = [];
for (let lineNumber = 0; lineNumber < document.lineCount; lineNumber++) {
const line = document.lineAt(lineNumber).text;
const todoInfo = this.findTodoInLine(line);
if (todoInfo) {
const { match, search, insertIndex } = todoInfo;
const range = new vscode.Range(lineNumber, search, lineNumber, search + match[0].length);
if (this.copilotRemoteAgentManager && (await this.copilotRemoteAgentManager.isAvailable())) {
const startAgentCodeLens = new vscode.CodeLens(range, {
title: vscode.l10n.t('Delegate to coding agent'),
command: 'issue.startCodingAgentFromTodo',
arguments: [{ document, lineNumber, line, insertIndex, range }],
});
codeLenses.push(startAgentCodeLens);
}
}
}
return codeLenses;
}
}
Loading