Skip to content
Draft
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
6 changes: 5 additions & 1 deletion src/issues/issueFeatureRegistrar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,8 +575,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('*', new IssueTodoProvider(this.context, this.copilotRemoteAgentManager), { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }),
vscode.languages.registerCodeActionsProvider('*', todoProvider, { providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }),
);
this._register(
vscode.languages.registerCodeLensProvider('*', todoProvider),
);
});
}
Expand Down
228 changes: 138 additions & 90 deletions src/issues/issueTodoProvider.ts
Original file line number Diff line number Diff line change
@@ -1,90 +1,138 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

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

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

constructor(
context: vscode.ExtensionContext,
private copilotRemoteAgentManager: CopilotRemoteAgentManager
) {
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(() => {
this.updateTriggers();
}),
);
this.updateTriggers();
}

private updateTriggers() {
const triggers = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_ISSUE_TRIGGERS, []);
this.expression = triggers.length > 0 ? new RegExp(triggers.map(trigger => escapeRegExp(trigger)).join('|')) : undefined;
}

async provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
context: vscode.CodeActionContext,
_token: vscode.CancellationToken,
): Promise<vscode.CodeAction[]> {
if (this.expression === undefined || (context.only && context.only !== vscode.CodeActionKind.QuickFix)) {
return [];
}
const codeActions: vscode.CodeAction[] = [];
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'),
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',
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('Start Coding Agent Session'),
vscode.CodeActionKind.QuickFix,
);
startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
startAgentAction.command = {
title: vscode.l10n.t('Start Coding Agent Session'),
command: 'issue.startCodingAgentFromTodo',
arguments: [{ document, lineNumber, line, insertIndex, range }],
};
codeActions.push(startAgentAction);
}
break;
}
}
lineNumber++;
} while (range.end.line >= lineNumber);
return codeActions;
}
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

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

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

constructor(
context: vscode.ExtensionContext,
private copilotRemoteAgentManager: CopilotRemoteAgentManager
) {
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(() => {
this.updateTriggers();
}),
);
this.updateTriggers();
}

private updateTriggers() {
const triggers = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE).get(CREATE_ISSUE_TRIGGERS, []);
this.expression = triggers.length > 0 ? new RegExp(triggers.map(trigger => escapeRegExp(trigger)).join('|')) : undefined;
}

async provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
context: vscode.CodeActionContext,
_token: vscode.CancellationToken,
): Promise<vscode.CodeAction[]> {
if (this.expression === undefined || (context.only && context.only !== vscode.CodeActionKind.QuickFix)) {
return [];
}
const codeActions: vscode.CodeAction[] = [];
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'),
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',
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('Start Coding Agent Session'),
vscode.CodeActionKind.QuickFix,
);
startAgentAction.ranges = [new vscode.Range(lineNumber, search, lineNumber, search + match[0].length)];
startAgentAction.command = {
title: vscode.l10n.t('Start Coding Agent Session'),
command: 'issue.startCodingAgentFromTodo',
arguments: [{ document, lineNumber, line, insertIndex, range }],
};
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 [];
}

const codeLenses: vscode.CodeLens[] = [];
for (let lineNumber = 0; lineNumber < document.lineCount; lineNumber++) {
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) {
const indexOfWhiteSpace = truncatedLine.substring(search).search(/\s/);
const insertIndex =
search +
(indexOfWhiteSpace > 0 ? indexOfWhiteSpace : truncatedLine.match(this.expression)![0].length);

const range = new vscode.Range(lineNumber, search, lineNumber, search + match[0].length);

// Only show "Start Coding Agent Session" code lens if copilot manager is available
if (this.copilotRemoteAgentManager) {
const startAgentCodeLens = new vscode.CodeLens(range, {
title: vscode.l10n.t('Start Coding Agent Session'),
command: 'issue.startCodingAgentFromTodo',
arguments: [{ document, lineNumber, line, insertIndex, range }],
});
codeLenses.push(startAgentCodeLens);
}
}
}
}
return codeLenses;
}

resolveCodeLens(
codeLens: vscode.CodeLens,
_token: vscode.CancellationToken,
): vscode.ProviderResult<vscode.CodeLens> {
// Code lens is already resolved in provideCodeLenses
return codeLens;
}
}
132 changes: 88 additions & 44 deletions src/test/issues/issueTodoProvider.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,89 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { default as assert } from 'assert';
import * as vscode from 'vscode';
import { IssueTodoProvider } from '../../issues/issueTodoProvider';

describe('IssueTodoProvider', function () {
it('should provide both actions when CopilotRemoteAgentManager is available', async function () {
const mockContext = {
subscriptions: []
} as any as vscode.ExtensionContext;

const mockCopilotManager = {} as any; // Mock CopilotRemoteAgentManager

const provider = new IssueTodoProvider(mockContext, mockCopilotManager);

// Create a mock document with TODO comment
const document = {
lineAt: (line: number) => ({ text: line === 1 ? ' // TODO: Fix this' : 'function test() {' }),
lineCount: 4
} as vscode.TextDocument;

const range = new vscode.Range(1, 0, 1, 20);
const context = {
only: vscode.CodeActionKind.QuickFix
} as vscode.CodeActionContext;

const actions = await provider.provideCodeActions(document, range, context, new vscode.CancellationTokenSource().token);

assert.strictEqual(actions.length, 2);

// Find the actions
const createIssueAction = actions.find(a => a.title === 'Create GitHub Issue');
const startAgentAction = actions.find(a => a.title === 'Start Coding Agent Session');

assert.ok(createIssueAction, 'Should have Create GitHub Issue action');
assert.ok(startAgentAction, 'Should have Start Coding Agent Session action');

assert.strictEqual(createIssueAction?.command?.command, 'issue.createIssueFromSelection');
assert.strictEqual(startAgentAction?.command?.command, 'issue.startCodingAgentFromTodo');
});
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { default as assert } from 'assert';
import * as vscode from 'vscode';
import { IssueTodoProvider } from '../../issues/issueTodoProvider';

describe('IssueTodoProvider', function () {
it('should provide both actions when CopilotRemoteAgentManager is available', async function () {
const mockContext = {
subscriptions: []
} as any as vscode.ExtensionContext;

const mockCopilotManager = {} as any; // Mock CopilotRemoteAgentManager

const provider = new IssueTodoProvider(mockContext, mockCopilotManager);

// Create a mock document with TODO comment
const document = {
lineAt: (line: number) => ({ text: line === 1 ? ' // TODO: Fix this' : 'function test() {' }),
lineCount: 4
} as vscode.TextDocument;

const range = new vscode.Range(1, 0, 1, 20);
const context = {
only: vscode.CodeActionKind.QuickFix
} as vscode.CodeActionContext;

const actions = await provider.provideCodeActions(document, range, context, new vscode.CancellationTokenSource().token);

assert.strictEqual(actions.length, 2);

// Find the actions
const createIssueAction = actions.find(a => a.title === 'Create GitHub Issue');
const startAgentAction = actions.find(a => a.title === 'Start Coding Agent Session');

assert.ok(createIssueAction, 'Should have Create GitHub Issue action');
assert.ok(startAgentAction, 'Should have Start Coding Agent Session action');

assert.strictEqual(createIssueAction?.command?.command, 'issue.createIssueFromSelection');
assert.strictEqual(startAgentAction?.command?.command, 'issue.startCodingAgentFromTodo');
});

it('should provide code lens for TODO comments when CopilotRemoteAgentManager is available', async function () {
const mockContext = {
subscriptions: []
} as any as vscode.ExtensionContext;

const mockCopilotManager = {} as any; // Mock CopilotRemoteAgentManager

const provider = new IssueTodoProvider(mockContext, mockCopilotManager);

// Create a mock document with TODO comment
const document = {
lineAt: (line: number) => ({ text: line === 1 ? ' // TODO: Fix this' : 'function test() {' }),
lineCount: 4
} as vscode.TextDocument;

const codeLenses = await provider.provideCodeLenses(document, new vscode.CancellationTokenSource().token);

assert.strictEqual(codeLenses.length, 1);

const codeLens = codeLenses[0];
assert.ok(codeLens.command, 'Code lens should have a command');
assert.strictEqual(codeLens.command.title, 'Start Coding Agent Session');
assert.strictEqual(codeLens.command.command, 'issue.startCodingAgentFromTodo');
assert.strictEqual(codeLens.range.start.line, 1);
});

it('should not provide code lens when CopilotRemoteAgentManager is not available', async function () {
const mockContext = {
subscriptions: []
} as any as vscode.ExtensionContext;

const provider = new IssueTodoProvider(mockContext, undefined);

// Create a mock document with TODO comment
const document = {
lineAt: (line: number) => ({ text: line === 1 ? ' // TODO: Fix this' : 'function test() {' }),
lineCount: 4
} as vscode.TextDocument;

const codeLenses = await provider.provideCodeLenses(document, new vscode.CancellationTokenSource().token);

assert.strictEqual(codeLenses.length, 0, 'Should not provide code lens when CopilotRemoteAgentManager is not available');
});
});