diff --git a/package.json b/package.json index 90109cb7a3..f2315d75b5 100644 --- a/package.json +++ b/package.json @@ -616,6 +616,21 @@ ], "description": "%githubIssues.createIssueTriggers.description%" }, + "githubIssues.createIssueCommentPrefixes": { + "type": "array", + "items": { + "type": "string", + "description": "%githubIssues.createIssueCommentPrefixes.items%" + }, + "default": [ + "//", + "#", + "--", + " * ", + "///" + ], + "description": "%githubIssues.createIssueCommentPrefixes.description%" + }, "githubPullRequests.codingAgent.codeLens": { "type": "boolean", "default": true, diff --git a/package.nls.json b/package.nls.json index 9a6b08abd8..55a0a85f61 100644 --- a/package.nls.json +++ b/package.nls.json @@ -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.", diff --git a/src/common/settingKeys.ts b/src/common/settingKeys.ts index 1376c4c7bc..d732ae4acc 100644 --- a/src/common/settingKeys.ts +++ b/src/common/settingKeys.ts @@ -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'; diff --git a/src/issues/issueTodoProvider.ts b/src/issues/issueTodoProvider.ts index 9232d7bf14..a12b7e3ea4 100644 --- a/src/issues/issueTodoProvider.ts +++ b/src/issues/issueTodoProvider.ts @@ -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, @@ -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(CREATE_ISSUE_TRIGGERS, []); + this.prefixTokens = issuesConfig.get(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( diff --git a/src/test/issues/issueTodoProvider.test.ts b/src/test/issues/issueTodoProvider.test.ts index af9bec18bc..4a808ee129 100644 --- a/src/test/issues/issueTodoProvider.test.ts +++ b/src/test/issues/issueTodoProvider.test.ts @@ -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; @@ -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; } });