Skip to content

Commit 4672447

Browse files
authored
Add confirmation to issue reporter (#1749)
<img width="2107" height="1450" alt="image" src="https://github.com/user-attachments/assets/582b619f-bcdd-4d10-9f49-cd0ebf3cafa8" /> Added confirmation step to issue reporter to make intent clear to users and avoid accidental issue creation This pull request makes improvements to user interaction and code organization in the `src/extension.ts` file. The main changes include adding a confirmation dialog before opening the issue reporter and making a small adjustment to the import order for better code clarity. **User Experience Improvements:** * Added a confirmation dialog that asks users to confirm before opening the issue reporter, reducing the chance of accidental submissions. The dialog appears after the user provides a description and only proceeds if they confirm. * Updated the description validation to require at least three characters, preventing empty or too-short submissions. **Code Organization:** * Reordered the import of `copyPathToClipboard` for improved consistency and readability in the import section.
1 parent 2668af8 commit 4672447

3 files changed

Lines changed: 183 additions & 140 deletions

File tree

src/extension.ts

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
} from './features/interpreterSelection';
7777
import { PythonProjectManagerImpl } from './features/projectManager';
7878
import { getPythonApi, setPythonApi } from './features/pythonApi';
79+
import { reportIssue } from './features/reportIssue';
7980
import { registerCompletionProvider } from './features/settings/settingCompletions';
8081
import { migrateGlobalDefaultEnvManagerSetting } from './features/settings/settingHelpers';
8182
import { setActivateMenuButtonContext } from './features/terminal/activateMenuButton';
@@ -95,7 +96,6 @@ import { updateViewsAndStatus } from './features/views/revealHandler';
9596
import { TemporaryStateManager } from './features/views/temporaryStateManager';
9697
import { PythonEnvTreeItem } from './features/views/treeViewItems';
9798
import {
98-
collectEnvironmentInfo,
9999
getEnvManagerAndPackageManagerConfigLevels,
100100
isInlineScriptsFeatureEnabled,
101101
runPetInTerminalImpl,
@@ -507,47 +507,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
507507
});
508508
},
509509
),
510-
commands.registerCommand('python-envs.reportIssue', async () => {
511-
try {
512-
// Prompt for issue title
513-
const rawTitle = await window.showInputBox({
514-
title: l10n.t('Report Issue - Title'),
515-
prompt: l10n.t('Enter a brief title for the issue'),
516-
placeHolder: l10n.t('e.g., Environment not detected, activation fails, etc.'),
517-
ignoreFocusOut: true,
518-
});
519-
const title = rawTitle?.trim();
520-
521-
if (!title) {
522-
// User cancelled or provided empty title
523-
return;
524-
}
525-
526-
// Prompt for issue description
527-
const rawDescription = await window.showInputBox({
528-
title: l10n.t('Report Issue - Description'),
529-
prompt: l10n.t('Describe the issue in more detail'),
530-
placeHolder: l10n.t('Provide additional context about what happened...'),
531-
ignoreFocusOut: true,
532-
});
533-
const description = rawDescription?.trim();
534-
535-
if (!description) {
536-
// User cancelled or provided empty description
537-
return;
538-
}
539-
540-
const issueData = await collectEnvironmentInfo(context, envManagers, projectManager);
541-
542-
await commands.executeCommand('workbench.action.openIssueReporter', {
543-
extensionId: 'ms-python.vscode-python-envs',
544-
issueTitle: `[Python Environments] ${title}`,
545-
issueBody: `## Description\n${description}\n\n## Steps to Reproduce\n1. \n2. \n3. \n\n## Expected Behavior\n\n\n## Actual Behavior\n\n\n<!-- The following information was automatically generated -->\n\n<details>\n<summary>Environment Information</summary>\n\n\`\`\`\n${issueData}\n\`\`\`\n\n</details>`,
546-
});
547-
} catch (error) {
548-
window.showErrorMessage(`Failed to open issue reporter: ${error}`);
549-
}
550-
}),
510+
commands.registerCommand('python-envs.reportIssue', () => reportIssue(context, envManagers, projectManager)),
551511
commands.registerCommand('python-envs.runPetInTerminal', async () => {
552512
try {
553513
await runPetInTerminalImpl();

src/features/reportIssue.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { ExtensionContext, l10n } from 'vscode';
2+
import * as commandApi from '../common/command.api';
3+
import { traceError } from '../common/logging';
4+
import * as windowApis from '../common/window.apis';
5+
import { collectEnvironmentInfo } from '../helpers';
6+
import { EnvironmentManagers, PythonProjectManager } from '../internal.api';
7+
8+
const MINIMUM_DESCRIPTION_LENGTH = 3;
9+
10+
/**
11+
* Collects issue details and opens a prefilled issue reporter after the user confirms diagnostic collection.
12+
*
13+
* @param context The extension context used to collect extension information.
14+
* @param envManagers The registered Python environment managers.
15+
* @param projectManager The Python project manager.
16+
*/
17+
export async function reportIssue(
18+
context: ExtensionContext,
19+
envManagers: EnvironmentManagers,
20+
projectManager: PythonProjectManager,
21+
): Promise<void> {
22+
try {
23+
const rawTitle = await windowApis.showInputBox({
24+
title: l10n.t('Report Issue - Title'),
25+
prompt: l10n.t('Enter a brief title for the issue'),
26+
placeHolder: l10n.t('e.g., Environment not detected, activation fails, etc.'),
27+
ignoreFocusOut: true,
28+
});
29+
const title = rawTitle?.trim();
30+
31+
if (!title) {
32+
return;
33+
}
34+
35+
const rawDescription = await windowApis.showInputBox({
36+
title: l10n.t('Report Issue - Description'),
37+
prompt: l10n.t('Describe the issue in more detail'),
38+
placeHolder: l10n.t('Provide additional context about what happened...'),
39+
ignoreFocusOut: true,
40+
validateInput: (value) =>
41+
value.trim().length < MINIMUM_DESCRIPTION_LENGTH
42+
? l10n.t('Enter at least {0} characters.', MINIMUM_DESCRIPTION_LENGTH)
43+
: undefined,
44+
});
45+
const description = rawDescription?.trim();
46+
47+
if (!description || description.length < MINIMUM_DESCRIPTION_LENGTH) {
48+
return;
49+
}
50+
51+
const continueAction = l10n.t('Continue to Issue Reporter');
52+
const confirmation = await windowApis.showInformationMessage(
53+
l10n.t(
54+
'To help the Python Environments team investigate, VS Code will collect details about your Python environments and projects and open a prefilled GitHub issue. You can review and edit it before submitting.',
55+
),
56+
{ modal: true },
57+
continueAction,
58+
);
59+
60+
if (confirmation !== continueAction) {
61+
return;
62+
}
63+
64+
const issueData = await collectEnvironmentInfo(context, envManagers, projectManager);
65+
66+
await commandApi.executeCommand('workbench.action.openIssueReporter', {
67+
extensionId: 'ms-python.vscode-python-envs',
68+
issueTitle: `[Python Environments] ${title}`,
69+
issueBody: `## Description\n${description}\n\n## Steps to Reproduce\n1. \n2. \n3. \n\n## Expected Behavior\n\n\n## Actual Behavior\n\n\n<!-- The following information was automatically generated -->\n\n<details>\n<summary>Environment Information</summary>\n\n\`\`\`\n${issueData}\n\`\`\`\n\n</details>`,
70+
});
71+
} catch (error) {
72+
traceError('Failed to open issue reporter', error);
73+
await windowApis.showErrorMessage(l10n.t('Failed to open the issue reporter. Please try again.'));
74+
}
75+
}
Lines changed: 106 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,120 @@
1-
/* eslint-disable @typescript-eslint/no-explicit-any */
21
import * as assert from 'assert';
3-
import * as typeMoq from 'typemoq';
4-
import * as vscode from 'vscode';
5-
import { PythonEnvironment, PythonEnvironmentId } from '../../api';
2+
import * as sinon from 'sinon';
3+
import { ExtensionContext, InputBoxOptions, l10n } from 'vscode';
4+
import * as commandApi from '../../common/command.api';
5+
import * as windowApis from '../../common/window.apis';
6+
import { reportIssue } from '../../features/reportIssue';
7+
import * as helpers from '../../helpers';
68
import { EnvironmentManagers, PythonProjectManager } from '../../internal.api';
7-
import { PythonProject } from '../../api';
8-
9-
// We need to mock the extension's activate function to test the collectEnvironmentInfo function
10-
// Since it's a local function, we'll test the command registration instead
119

1210
suite('Report Issue Command Tests', () => {
13-
let mockEnvManagers: typeMoq.IMock<EnvironmentManagers>;
14-
let mockProjectManager: typeMoq.IMock<PythonProjectManager>;
11+
const context = {} as ExtensionContext;
12+
const envManagers = {} as EnvironmentManagers;
13+
const projectManager = {} as PythonProjectManager;
1514

16-
setup(() => {
17-
mockEnvManagers = typeMoq.Mock.ofType<EnvironmentManagers>();
18-
mockProjectManager = typeMoq.Mock.ofType<PythonProjectManager>();
15+
teardown(() => {
16+
sinon.restore();
1917
});
2018

21-
test('should handle environment collection with empty data', () => {
22-
mockEnvManagers.setup((em) => em.managers).returns(() => []);
23-
mockProjectManager.setup((pm) => pm.getProjects(typeMoq.It.isAny())).returns(() => []);
24-
25-
// Test that empty collections are handled gracefully
26-
const managers = mockEnvManagers.object.managers;
27-
const projects = mockProjectManager.object.getProjects();
28-
29-
assert.strictEqual(managers.length, 0);
30-
assert.strictEqual(projects.length, 0);
19+
test('stops when the title input is cancelled', async () => {
20+
sinon.stub(windowApis, 'showInputBox').resolves(undefined);
21+
const collectEnvironmentInfo = sinon.stub(helpers, 'collectEnvironmentInfo');
22+
const executeCommand = sinon.stub(commandApi, 'executeCommand');
23+
24+
await reportIssue(context, envManagers, projectManager);
25+
26+
sinon.assert.notCalled(collectEnvironmentInfo);
27+
sinon.assert.notCalled(executeCommand);
3128
});
3229

33-
test('should handle environment collection with mock data', async () => {
34-
// Create mock environment
35-
const mockEnvId: PythonEnvironmentId = {
36-
id: 'test-env-id',
37-
managerId: 'test-manager'
38-
};
39-
40-
const mockEnv: PythonEnvironment = {
41-
envId: mockEnvId,
42-
name: 'Test Environment',
43-
displayName: 'Test Environment 3.9',
44-
displayPath: '/path/to/python',
45-
version: '3.9.0',
46-
environmentPath: vscode.Uri.file('/path/to/env'),
47-
execInfo: {
48-
run: {
49-
executable: '/path/to/python',
50-
args: []
51-
}
52-
},
53-
sysPrefix: '/path/to/env'
54-
};
55-
56-
const mockManager = {
57-
id: 'test-manager',
58-
displayName: 'Test Manager',
59-
getEnvironments: async () => [mockEnv]
60-
} as any;
61-
62-
// Create mock project
63-
const mockProject: PythonProject = {
64-
uri: vscode.Uri.file('/path/to/project'),
65-
name: 'Test Project'
66-
};
67-
68-
mockEnvManagers.setup((em) => em.managers).returns(() => [mockManager]);
69-
mockProjectManager.setup((pm) => pm.getProjects(typeMoq.It.isAny())).returns(() => [mockProject]);
70-
mockEnvManagers.setup((em) => em.getEnvironment(typeMoq.It.isAny())).returns(() => Promise.resolve(mockEnv));
71-
72-
// Verify mocks are set up correctly
73-
const managers = mockEnvManagers.object.managers;
74-
const projects = mockProjectManager.object.getProjects();
75-
76-
assert.strictEqual(managers.length, 1);
77-
assert.strictEqual(projects.length, 1);
78-
assert.strictEqual(managers[0].id, 'test-manager');
79-
assert.strictEqual(projects[0].name, 'Test Project');
30+
test('stops when the description input is cancelled', async () => {
31+
sinon.stub(windowApis, 'showInputBox').onFirstCall().resolves('Issue title').onSecondCall().resolves(undefined);
32+
const collectEnvironmentInfo = sinon.stub(helpers, 'collectEnvironmentInfo');
33+
const executeCommand = sinon.stub(commandApi, 'executeCommand');
34+
35+
await reportIssue(context, envManagers, projectManager);
36+
37+
sinon.assert.notCalled(collectEnvironmentInfo);
38+
sinon.assert.notCalled(executeCommand);
8039
});
8140

82-
test('should handle errors gracefully during environment collection', async () => {
83-
const mockManager = {
84-
id: 'error-manager',
85-
displayName: 'Error Manager',
86-
getEnvironments: async () => {
87-
throw new Error('Test error');
88-
}
89-
} as any;
90-
91-
mockEnvManagers.setup((em) => em.managers).returns(() => [mockManager]);
92-
mockProjectManager.setup((pm) => pm.getProjects(typeMoq.It.isAny())).returns(() => []);
93-
94-
// Verify that error conditions don't break the test setup
95-
const managers = mockEnvManagers.object.managers;
96-
assert.strictEqual(managers.length, 1);
97-
assert.strictEqual(managers[0].id, 'error-manager');
41+
test('validates the minimum description length in the input box', async () => {
42+
const showInputBox = sinon
43+
.stub(windowApis, 'showInputBox')
44+
.onFirstCall()
45+
.resolves('Issue title')
46+
.onSecondCall()
47+
.resolves(undefined);
48+
49+
await reportIssue(context, envManagers, projectManager);
50+
51+
const options = showInputBox.secondCall.args[0] as InputBoxOptions;
52+
assert.ok(options.validateInput);
53+
assert.strictEqual(await options.validateInput('ab'), l10n.t('Enter at least {0} characters.', 3));
54+
assert.strictEqual(await options.validateInput('abc'), undefined);
9855
});
9956

100-
test('should register report issue command', () => {
101-
// Basic test to ensure command registration structure would work
102-
// The actual command registration happens during extension activation
103-
// This tests the mock setup and basic functionality
104-
105-
mockEnvManagers.setup((em) => em.managers).returns(() => []);
106-
mockProjectManager.setup((pm) => pm.getProjects(typeMoq.It.isAny())).returns(() => []);
107-
108-
// Verify basic setup works
109-
assert.notStrictEqual(mockEnvManagers.object, undefined);
110-
assert.notStrictEqual(mockProjectManager.object, undefined);
57+
test('does not collect environment information when confirmation is dismissed', async () => {
58+
sinon.stub(windowApis, 'showInputBox').onFirstCall().resolves('Issue title').onSecondCall().resolves('Details');
59+
sinon.stub(windowApis, 'showInformationMessage').resolves(undefined);
60+
const collectEnvironmentInfo = sinon.stub(helpers, 'collectEnvironmentInfo');
61+
const executeCommand = sinon.stub(commandApi, 'executeCommand');
62+
63+
await reportIssue(context, envManagers, projectManager);
64+
65+
sinon.assert.notCalled(collectEnvironmentInfo);
66+
sinon.assert.notCalled(executeCommand);
67+
});
68+
69+
test('opens a prefilled issue reporter after confirmation', async () => {
70+
sinon
71+
.stub(windowApis, 'showInputBox')
72+
.onFirstCall()
73+
.resolves(' Issue title ')
74+
.onSecondCall()
75+
.resolves(' Issue details ');
76+
const showInformationMessage = sinon
77+
.stub(windowApis, 'showInformationMessage')
78+
.resolves(l10n.t('Continue to Issue Reporter'));
79+
const collectEnvironmentInfo = sinon.stub(helpers, 'collectEnvironmentInfo').resolves('Environment details');
80+
const executeCommand = sinon.stub(commandApi, 'executeCommand').resolves();
81+
82+
await reportIssue(context, envManagers, projectManager);
83+
84+
sinon.assert.calledOnce(showInformationMessage);
85+
assert.deepStrictEqual(showInformationMessage.firstCall.args, [
86+
l10n.t(
87+
'To help the Python Environments team investigate, VS Code will collect details about your Python environments and projects and open a prefilled GitHub issue. You can review and edit it before submitting.',
88+
),
89+
{ modal: true },
90+
l10n.t('Continue to Issue Reporter'),
91+
]);
92+
sinon.assert.calledOnceWithExactly(collectEnvironmentInfo, context, envManagers, projectManager);
93+
sinon.assert.calledOnce(executeCommand);
94+
assert.strictEqual(executeCommand.firstCall.args[0], 'workbench.action.openIssueReporter');
95+
assert.deepStrictEqual(executeCommand.firstCall.args[1], {
96+
extensionId: 'ms-python.vscode-python-envs',
97+
issueTitle: '[Python Environments] Issue title',
98+
issueBody:
99+
'## Description\nIssue details\n\n## Steps to Reproduce\n1. \n2. \n3. \n\n## Expected Behavior\n\n\n' +
100+
'## Actual Behavior\n\n\n<!-- The following information was automatically generated -->\n\n<details>\n' +
101+
'<summary>Environment Information</summary>\n\n```\nEnvironment details\n```\n\n</details>',
102+
});
103+
});
104+
105+
test('shows a localized error when opening the issue reporter fails', async () => {
106+
sinon.stub(windowApis, 'showInputBox').onFirstCall().resolves('Issue title').onSecondCall().resolves('Details');
107+
sinon.stub(windowApis, 'showInformationMessage').resolves(l10n.t('Continue to Issue Reporter'));
108+
sinon.stub(helpers, 'collectEnvironmentInfo').resolves('Environment details');
109+
sinon.stub(commandApi, 'executeCommand').rejects(new Error('Reporter failed'));
110+
const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined);
111+
112+
await reportIssue(context, envManagers, projectManager);
113+
114+
sinon.assert.calledOnce(showErrorMessage);
115+
assert.strictEqual(
116+
showErrorMessage.firstCall.args[0],
117+
l10n.t('Failed to open the issue reporter. Please try again.'),
118+
);
111119
});
112-
});
120+
});

0 commit comments

Comments
 (0)