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": [
"//",
"#",
"--",
" * ",
"///"
],
"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
118 changes: 116 additions & 2 deletions src/test/issues/issueTodoProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,34 @@ 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: []
} as any as vscode.ExtensionContext;

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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
});
});