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..ea26413cc2 100644 --- a/src/test/issues/issueTodoProvider.test.ts +++ b/src/test/issues/issueTodoProvider.test.ts @@ -7,7 +7,7 @@ import { default as assert } from 'assert'; import * as vscode from 'vscode'; import { IssueTodoProvider } from '../../issues/issueTodoProvider'; -describe.skip('IssueTodoProvider', function () { +describe('IssueTodoProvider', function () { it('should provide both actions when CopilotRemoteAgentManager is available', async function () { const mockContext = { subscriptions: [] @@ -15,11 +15,26 @@ describe.skip('IssueTodoProvider', function () { const mockCopilotManager = {} as any; // Mock CopilotRemoteAgentManager + // 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' : 'function test() {}' }), lineCount: 4 } as vscode.TextDocument; @@ -50,6 +65,19 @@ describe.skip('IssueTodoProvider', function () { const mockCopilotManager = {} as any; // Mock CopilotRemoteAgentManager + 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 @@ -128,4 +156,90 @@ describe.skip('IssueTodoProvider', function () { vscode.workspace.getConfiguration = originalGetConfiguration; } }); + + it('should not trigger on line without comment prefix', async function () { + const mockContext = { subscriptions: [] } as any as vscode.ExtensionContext; + const mockCopilotManager = {} as any; + + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + if (section === 'githubIssues') { + return { + get: (key: string, defaultValue?: any) => { + if (key === 'createIssueTriggers') { return ['DEBUG_RUN']; } + if (key === 'createIssueCommentPrefixes') { return ['//']; } + return defaultValue; + } + } as any; + } + return originalGetConfiguration(section); + }; + + const provider = new IssueTodoProvider(mockContext, mockCopilotManager); + + const testLine = "\tregisterTouchBarEntry(DEBUG_RUN_COMMAND_ID, DEBUG_RUN_LABEL, 0, CONTEXT_IN_DEBUG_MODE.toNegated(), FileAccess.asFileUri('vs/workbench/contrib/debug/browser/media/continue-tb.png'));"; + const document = { + lineAt: (_line: number) => ({ text: testLine }), + lineCount: 1 + } as vscode.TextDocument; + + const codeLenses = await provider.provideCodeLenses(document, new vscode.CancellationTokenSource().token); + assert.strictEqual(codeLenses.length, 0, 'Should not create CodeLens for trigger inside code without prefix'); + + vscode.workspace.getConfiguration = originalGetConfiguration; // restore + }); + + it('prefix matrix detection', async function () { + const mockContext = { subscriptions: [] } as any as vscode.ExtensionContext; + const mockCopilotManager = { isAvailable: async () => true } as any; + + 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: '\t* 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 }, + { 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 }, + ]; + + 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; + } + if (section === 'githubPullRequests.codingAgent') { + return { get: () => true } as any; + } + return originalGetConfiguration(section); + }; + + try { + 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, `Mismatch for line: "${tc.testLine}"`); + } + } finally { + vscode.workspace.getConfiguration = originalGetConfiguration; + } + }); }); \ No newline at end of file