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
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,21 @@
],
"description": "%githubIssues.createIssueTriggers.description%"
},
"githubIssues.createIssueCommentPrefixes": {
"type": "array",
"items": {
"type": "string",
"description": "%githubIssues.createIssueCommentPrefixes.items%"
},
"default": [
"//",
"#",
"--",
" * ",
"///"
Comment on lines +625 to +630
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems suspicious. It's something that would be language specific too.

<!-- TODO: ...... -->
/*
TODO: some multi-line
comment with context
*/
<#
TODO: PowerShell block comments are weird
I know
#>

https://github.com/microsoft/vscode/blob/55f06131905f07dde60f301aa56be6e2f52155a3/extensions/make/language-configuration.json#L2-L7

The comment syntax is declared by the language support, I feel like we should somehow leverage that if we can.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/microsoft/vscode/blob/55f06131905f07dde60f301aa56be6e2f52155a3/src/vs/editor/common/languages/languageConfigurationRegistry.ts#L35-L47

we have this in core... and we have a number of commands in core that are about retrieval of symbols:
https://code.visualstudio.com/api/references/commands#commands

I wonder if this warrants some way to get the LanguageConfiguration via a Command. Or at least just the comment stuff...

@hediet @alexdima I see your name around this code, maybe you have ideas

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the pointers, this looks a lot more correct.

I justified that this was ok since it's configurable via a setting and did not know we could possibly leverage core for this information.

],
"description": "%githubIssues.createIssueCommentPrefixes.description%"
},
"githubPullRequests.codingAgent.codeLens": {
"type": "boolean",
"default": true,
Expand Down
16 changes: 15 additions & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,21 @@
"githubPullRequests.experimental.useQuickChat.description": "Controls whether the Copilot \"Summarize\" commands in the Pull Requests, Issues, and Notifications views will use quick chat. Only has an effect if `#githubPullRequests.experimental.chat#` is enabled.",
"githubPullRequests.webviewRefreshInterval.description": "The interval, in seconds, at which the pull request and issues webviews are refreshed when the webview is the active tab.",
"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.description": {
"message": "Trigger tokens found after a token in `#githubIssues.createIssueCommentPrefixes#` will show the 'Create issue from comment' code action. These tokens also enable the 'Delegate to Coding Agent' code lens if `#githubPullRequests.codingAgent.codeLens#` is enabled.",
"comment": [
"{Locked='`...`'}",
"Do not translate what's inside of the `...`. It is a setting id."
]
},
"githubIssues.createIssueCommentPrefixes.description": {
"message": "Comment prefixes (e.g. //, #, --) that must immediately precede a trigger token from `#githubIssues.createIssueTriggers#` to activate the issue actions / code lens.",
"comment": [
"{Locked='`#githubIssues.createIssueTriggers#`'}",
"Do not translate what's inside of the `...`. It is a setting id."
]
},
"githubIssues.createIssueCommentPrefixes.items": "Comment prefix used to detect issue trigger tokens (e.g. //, #, --).",
"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.",
Expand Down
2 changes: 2 additions & 0 deletions src/common/settingKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const WORKING_ISSUE_FORMAT_SCM = 'workingIssueFormatScm';
export const IGNORE_COMPLETION_TRIGGER = 'ignoreCompletionTrigger';
export const ISSUE_COMPLETION_FORMAT_SCM = 'issueCompletionFormatScm';
export const CREATE_ISSUE_TRIGGERS = 'createIssueTriggers';
// Comment prefixes that, when followed by a trigger token, cause issue actions to appear
export const CREATE_ISSUE_COMMENT_PREFIXES = 'createIssueCommentPrefixes';
export const DEFAULT = 'default';
export const IGNORE_MILESTONES = 'ignoreMilestones';
export const ALLOW_FETCH = 'allowFetch';
Expand Down
46 changes: 32 additions & 14 deletions src/issues/issueTodoProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@

import * as vscode from 'vscode';
import { MAX_LINE_LENGTH } from './util';
import { CODING_AGENT, CREATE_ISSUE_TRIGGERS, ISSUES_SETTINGS_NAMESPACE, SHOW_CODE_LENS } from '../common/settingKeys';
import { CODING_AGENT, CREATE_ISSUE_COMMENT_PREFIXES, 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, vscode.CodeLensProvider {
private expression: RegExp | undefined;
private triggerTokens: string[] = [];
private prefixTokens: string[] = [];

constructor(
context: vscode.ExtensionContext,
Expand All @@ -26,26 +28,42 @@ export class IssueTodoProvider implements vscode.CodeActionProvider, vscode.Code
}

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;
const issuesConfig = vscode.workspace.getConfiguration(ISSUES_SETTINGS_NAMESPACE);
this.triggerTokens = issuesConfig.get<string[]>(CREATE_ISSUE_TRIGGERS, []);
this.prefixTokens = issuesConfig.get<string[]>(CREATE_ISSUE_COMMENT_PREFIXES, []);
if (this.triggerTokens.length === 0 || this.prefixTokens.length === 0) {
this.expression = undefined;
return;
}
// Build a regex that captures the trigger word so we can highlight just that portion
// ^\s*(?:prefix1|prefix2)\s*(trigger1|trigger2)\b
const prefixesSource = this.prefixTokens.map(p => escapeRegExp(p)).join('|');
const triggersSource = this.triggerTokens.map(t => escapeRegExp(t)).join('|');
this.expression = new RegExp(`^\\s*(?:${prefixesSource})\\s*(${triggersSource})\\b`);
}

private findTodoInLine(line: string): { match: RegExpMatchArray; search: number; insertIndex: number } | undefined {
if (!this.expression) {
return undefined;
}
const truncatedLine = line.substring(0, MAX_LINE_LENGTH);
const matches = truncatedLine.match(ISSUE_OR_URL_EXPRESSION);
if (matches) {
// If the line already contains an issue reference or URL, skip
if (ISSUE_OR_URL_EXPRESSION.test(truncatedLine)) {
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 };
const match = this.expression.exec(truncatedLine);
if (!match) {
return undefined;
}
return undefined;
// match[1] is the captured trigger token
const fullMatch = match[0];
const trigger = match[1];
// Find start of trigger within full line for highlighting
const triggerStartInFullMatch = fullMatch.lastIndexOf(trigger); // safe since trigger appears once at end
const search = match.index + triggerStartInFullMatch;
const insertIndex = search + trigger.length;
// Return a RegExpMatchArray-like structure; reuse match
return { match, search, insertIndex };
}

async provideCodeActions(
Expand Down
129 changes: 59 additions & 70 deletions src/test/issues/issueTodoProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,39 @@ import { default as assert } from 'assert';
import * as vscode from 'vscode';
import { IssueTodoProvider } from '../../issues/issueTodoProvider';

describe.skip('IssueTodoProvider', function () {
// Simple factory for a CopilotRemoteAgentManager mock that always reports availability.
function createAvailableCopilotManager() {
return { isAvailable: async () => true } as any;
}

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 mockCopilotManager = createAvailableCopilotManager();

// Mock configuration for triggers and prefixes
const originalGetConfiguration = vscode.workspace.getConfiguration;
vscode.workspace.getConfiguration = (section?: string) => {
if (section === 'githubIssues') {
return {
get: (key: string, defaultValue?: any) => {
if (key === 'createIssueTriggers') { return ['TODO']; }
if (key === 'createIssueCommentPrefixes') { return ['//']; }
return defaultValue;
}
} as any;
}
return originalGetConfiguration(section);
};

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() {' }),
lineAt: (line: number) => ({ text: line === 1 ? ' // TODO: Fix this' : '// DEBUG: function test() {' }),
lineCount: 4
} as vscode.TextDocument;

Expand All @@ -43,88 +63,57 @@ describe.skip('IssueTodoProvider', function () {
assert.strictEqual(startAgentAction?.command?.command, 'issue.startCodingAgentFromTodo');
});

it('should provide code lenses for TODO comments', 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;
it('prefix matrix detection', async function () {
const mockContext = { subscriptions: [] } as any as vscode.ExtensionContext;
const mockCopilotManager = createAvailableCopilotManager();

const testCases: { testLine: string; expected: boolean; note?: string }[] = [
{ testLine: ' // TODO implement feature', expected: true },
{ testLine: '\t//TODO implement feature', expected: true },
{ testLine: ' # TODO spaced hash', expected: true },
{ testLine: '-- TODO dash dash', expected: true },
{ testLine: ' * TODO docblock star', expected: true },
{ testLine: ' * TODO extra spaces after star', expected: true },
{ testLine: '/// TODO rust style', expected: true },
{ testLine: '///TODO rust tight', expected: true },
{ testLine: 'let x = 0; // TODO not at line start so should not match', expected: false }, // TODO: Detect inline TODO comments
{ testLine: ' *TODO (no space after star)', expected: false },
{ testLine: ' * NotATrigger word', expected: false },
{ testLine: '/* TODO inside block start should not (prefix not configured)', expected: false },
{ testLine: 'random text TODO (no prefix)', expected: false },
{ testLine: '#TODO tight hash', expected: true },
{ testLine: 'registerTouchBarEntry(DEBUG_RUN_COMMAND_ID, DEBUG_RUN_LABEL, 0, CONTEXT_IN_DEBUG_MODE.toNegated(), FileAccess.asFileUri(\'vs/workbench/contrib/debug/browser/media/continue-tb.png\')', expected: false }
];

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

assert.strictEqual(codeLenses.length, 2);

// Verify the code lenses
const createIssueLens = codeLenses.find(cl => cl.command?.title === 'Create GitHub Issue');
const startAgentLens = codeLenses.find(cl => cl.command?.title === 'Delegate to coding agent');

assert.ok(createIssueLens, 'Should have Create GitHub Issue CodeLens');
assert.ok(startAgentLens, 'Should have Delegate to coding agent CodeLens');

assert.strictEqual(createIssueLens?.command?.command, 'issue.createIssueFromSelection');
assert.strictEqual(startAgentLens?.command?.command, 'issue.startCodingAgentFromTodo');

// Verify the range points to the TODO text
assert.strictEqual(createIssueLens?.range.start.line, 1);
assert.strictEqual(startAgentLens?.range.start.line, 1);
});

it('should respect the createIssueCodeLens setting', 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;

// Mock the workspace configuration to return false for createIssueCodeLens
const originalGetConfiguration = vscode.workspace.getConfiguration;
vscode.workspace.getConfiguration = (section?: string) => {
if (section === 'githubIssues') {
return {
get: (key: string, defaultValue?: any) => {
if (key === 'createIssueCodeLens') {
return false;
}
if (key === 'createIssueTriggers') {
return ['TODO', 'todo', 'BUG', 'FIXME', 'ISSUE', 'HACK'];
}
if (key === 'createIssueTriggers') { return ['TODO']; }
if (key === 'createIssueCommentPrefixes') { return ['//', '#', '--', ' * ', '///']; }
return defaultValue;
}
} as any;
}
if (section === 'githubPullRequests.codingAgent') {
return { get: () => true } as any;
}
return originalGetConfiguration(section);
};

try {
// Update triggers to ensure the expression is set
(provider as any).updateTriggers();

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

// Should return empty array when CodeLens is disabled
assert.strictEqual(codeLenses.length, 0, 'Should not provide code lenses when setting is disabled');
const provider = new IssueTodoProvider(mockContext, mockCopilotManager);
for (const tc of testCases) {
const document = {
lineAt: (_line: number) => ({ text: tc.testLine }),
lineCount: 1
} as vscode.TextDocument;
const codeLenses = await provider.provideCodeLenses(document, new vscode.CancellationTokenSource().token);
const detected = codeLenses.length > 0;
assert.strictEqual(detected, tc.expected, `Unexpected result (expected=${tc.expected}) for line: "${tc.testLine}"`);
}
} finally {
// Restore original configuration
vscode.workspace.getConfiguration = originalGetConfiguration;
}
});
Expand Down