Skip to content

Commit 2bc1f5f

Browse files
[AXON-385] Use JIRA AI to populate createIssueView fileds
1 parent bba8962 commit 2bc1f5f

File tree

16 files changed

+744
-74
lines changed

16 files changed

+744
-74
lines changed

e2e/tsconfig.e2e.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"compilerOptions": {
3+
"baseUrl": ".",
4+
"outDir": "../.generated",
5+
"module": "CommonJS",
6+
"target": "es2018",
7+
"lib": ["ESNext", "dom"],
8+
"alwaysStrict": true,
9+
"resolveJsonModule": true,
10+
"skipLibCheck": true,
11+
"sourceMap": true,
12+
"allowJs": true,
13+
"jsx": "react",
14+
"moduleResolution": "node",
15+
"rootDir": "../..",
16+
"allowSyntheticDefaultImports": true,
17+
"forceConsistentCasingInFileNames": true,
18+
"noImplicitReturns": true,
19+
"noImplicitThis": true,
20+
"noImplicitAny": true,
21+
"strictNullChecks": true,
22+
"noUnusedLocals": true,
23+
"noFallthroughCasesInSwitch": true,
24+
"strictPropertyInitialization": true,
25+
"typeRoots": ["node_modules/@types", "src/typings/"],
26+
"paths": {
27+
"testsutil/*": ["testsutil/*"]
28+
}
29+
},
30+
"include": ["tests/**/*"]
31+
}

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,21 @@
809809
"type": "object",
810810
"title": "Atlassian",
811811
"properties": {
812+
"atlascode.issueSuggestion.enabled": {
813+
"type": "boolean",
814+
"default": true,
815+
"description": "Enables AI-assisted issue suggestion for TODO comments",
816+
"scope": "window"
817+
},
818+
"atlascode.issueSuggestion.contextLevel": {
819+
"type": "string",
820+
"description": "What context to use for issue suggestions - only the TODO text, surrouding code, or the entire file",
821+
"default": "codeContext",
822+
"enum": [
823+
"todoOnly",
824+
"codeContext"
825+
]
826+
},
812827
"atlascode.outputLevel": {
813828
"type": "string",
814829
"default": "silent",

src/atlclients/issueBuilder.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { AxiosInstance } from 'axios';
2+
import { Container } from 'src/container';
3+
4+
import { getAxiosInstance } from '../jira/jira-client/providers';
5+
import { BasicAuthInfo, DetailedSiteInfo, isBasicAuthInfo, ProductJira } from './authInfo';
6+
7+
export type SuggestedIssue = {
8+
issueType: string;
9+
fieldValues: {
10+
summary: string;
11+
description: string;
12+
};
13+
};
14+
15+
export type SuggestedIssuesResponse = {
16+
suggestedIssues: SuggestedIssue[];
17+
};
18+
19+
export const findCloudSiteWithApiKey = async (): Promise<DetailedSiteInfo | null> => {
20+
const sites = await Promise.all(
21+
Container.siteManager.getSitesAvailable(ProductJira).map(async (site) => {
22+
if (!site.host.endsWith('.atlassian.net')) {
23+
return null;
24+
}
25+
26+
const authInfo = await Container.credentialManager.getAuthInfo(site);
27+
if (!authInfo || !isBasicAuthInfo(authInfo)) {
28+
return null;
29+
}
30+
31+
return site;
32+
}),
33+
).then((results) => results.filter(Boolean));
34+
35+
// Any site is fine, just need an API key
36+
const site = sites[0];
37+
return site;
38+
};
39+
40+
export const fetchIssueSuggestions = async (prompt: string): Promise<SuggestedIssuesResponse> => {
41+
const axiosInstance: AxiosInstance = getAxiosInstance();
42+
43+
try {
44+
const site = await findCloudSiteWithApiKey();
45+
46+
if (!site) {
47+
throw new Error('No site found with API key');
48+
}
49+
50+
const authInfo = (await Container.credentialManager.getAuthInfo(site)) as BasicAuthInfo;
51+
if (!authInfo || !isBasicAuthInfo(authInfo)) {
52+
throw new Error('No valid auth info found for site');
53+
}
54+
55+
const response = await axiosInstance.post(
56+
`https://${site.host}/gateway/api/assist/chat/v1/invoke_agent`,
57+
{
58+
recipient_agent_named_id: 'ai_issue_create_agent',
59+
agent_input_context: {
60+
application: 'Slack',
61+
context: {
62+
primary_message: { text: prompt },
63+
},
64+
suggested_issues_config: {
65+
max_issues: 1,
66+
suggested_issue_field_types: [
67+
{
68+
issue_type: 'Task',
69+
fields: [
70+
{
71+
field_name: 'Summary',
72+
field_type: 'Short-Text',
73+
},
74+
{
75+
field_name: 'Description',
76+
field_type: 'Paragraph',
77+
},
78+
],
79+
},
80+
],
81+
},
82+
},
83+
},
84+
{
85+
headers: {
86+
'Content-Type': 'application/json;charset=UTF-8',
87+
Accept: 'application/json;charset=UTF-8',
88+
'X-Experience-Id': 'ai-issue-create-slack',
89+
'X-Product': 'jira',
90+
Authorization:
91+
'Basic ' + Buffer.from(`${authInfo.username}:${authInfo.password}`).toString('base64'),
92+
},
93+
},
94+
);
95+
const content = JSON.parse(response.data.message.content);
96+
97+
const responseData: SuggestedIssuesResponse = {
98+
suggestedIssues: content.suggested_issues.map((issue: any) => ({
99+
issueType: issue.issue_type,
100+
fieldValues: {
101+
summary: issue.field_values.Summary,
102+
description: issue.field_values.Description,
103+
},
104+
})),
105+
};
106+
107+
if (!responseData.suggestedIssues || responseData.suggestedIssues.length === 0) {
108+
throw new Error('No suggested issues found');
109+
}
110+
111+
return responseData;
112+
} catch (error) {
113+
console.error('Error fetching issue suggestions:', error);
114+
throw error;
115+
}
116+
};

src/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export function registerCommands(vscodeContext: ExtensionContext) {
171171
}
172172
},
173173
),
174-
commands.registerCommand(Commands.CreateIssue, (data: any, source?: string) => createIssue(data, source)),
174+
commands.registerCommand(Commands.CreateIssue, async (data: any, source?: string) => createIssue(data, source)),
175175
commands.registerCommand(
176176
Commands.ShowIssue,
177177
async (issueOrKeyAndSite: MinimalIssueOrKeyAndSite<DetailedSiteInfo>) => await showIssue(issueOrKeyAndSite),

src/commands/jira/createIssue.ts

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,72 @@
1+
import { SimplifiedTodoIssueData } from 'src/config/model';
2+
import { Logger } from 'src/logger';
13
import { Position, Range, Uri, ViewColumn, window, workspace, WorkspaceEdit } from 'vscode';
24

35
import { startIssueCreationEvent } from '../../analytics';
46
import { ProductJira } from '../../atlclients/authInfo';
57
import { WorkspaceRepo } from '../../bitbucket/model';
68
import { Container } from '../../container';
79
import { CommentData } from '../../webviews/createIssueWebview';
10+
import { IssueSuggestionManager } from './issueSuggestionManager';
811

912
export interface TodoIssueData {
1013
summary: string;
1114
uri: Uri;
1215
insertionPoint: Position;
16+
context: string;
1317
}
1418

15-
export function createIssue(data: Uri | TodoIssueData | undefined, source?: string) {
19+
const simplify = (data: TodoIssueData): SimplifiedTodoIssueData => {
20+
return {
21+
summary: data.summary,
22+
context: data.context,
23+
position: {
24+
line: data.insertionPoint.line,
25+
character: data.insertionPoint.character,
26+
},
27+
uri: data.uri.toString(),
28+
};
29+
};
30+
31+
export async function createIssue(data: Uri | TodoIssueData | undefined, source?: string) {
1632
if (isTodoIssueData(data)) {
17-
const partialIssue = {
18-
summary: data.summary,
19-
description: descriptionForUri(data.uri),
20-
uri: data.uri,
21-
position: data.insertionPoint,
22-
onCreated: annotateComment,
23-
};
24-
Container.createIssueWebview.createOrShow(ViewColumn.Beside, partialIssue);
33+
const settings = await IssueSuggestionManager.buildSettings();
34+
const todoData = simplify(data);
35+
36+
await Container.createIssueWebview.createOrShow(
37+
ViewColumn.Beside,
38+
{
39+
description: descriptionForUri(data.uri),
40+
uri: data.uri,
41+
position: data.insertionPoint,
42+
onCreated: annotateComment,
43+
},
44+
settings,
45+
todoData,
46+
);
47+
48+
try {
49+
const suggestionManager = new IssueSuggestionManager(settings);
50+
51+
await suggestionManager.generate(todoData).then(async (suggestion) => {
52+
await Container.createIssueWebview.forceUpdateFields({
53+
summary: suggestion.summary,
54+
description: suggestion.description,
55+
});
56+
});
57+
} catch (error) {
58+
// The view is already created with legacy logic, do nothing
59+
Logger.error(error, 'Error generating issue suggestion settings');
60+
}
61+
2562
startIssueCreationEvent('todoComment', ProductJira).then((e) => {
2663
Container.analyticsClient.sendTrackEvent(e);
2764
});
65+
2866
return;
29-
} else if (isUri(data) && data.scheme === 'file') {
67+
}
68+
69+
if (isUri(data) && data.scheme === 'file') {
3070
Container.createIssueWebview.createOrShow(ViewColumn.Active, { description: descriptionForUri(data) });
3171
startIssueCreationEvent('contextMenu', ProductJira).then((e) => {
3272
Container.analyticsClient.sendTrackEvent(e);
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { FeatureFlagClient, Features } from 'src/util/featureFlags';
2+
import { window, workspace } from 'vscode';
3+
4+
import { fetchIssueSuggestions, findCloudSiteWithApiKey } from '../../atlclients/issueBuilder';
5+
import { IssueSuggestionContextLevel, IssueSuggestionSettings, SimplifiedTodoIssueData } from '../../config/model';
6+
7+
export class IssueSuggestionManager {
8+
static getSuggestionEnabled(): boolean {
9+
const config = workspace.getConfiguration('atlascode.issueSuggestion').get<boolean>('enabled');
10+
return config === true; // (as opposed to undefined)
11+
}
12+
13+
static getSuggestionContextLevel(): IssueSuggestionContextLevel {
14+
const config = workspace
15+
.getConfiguration('atlascode')
16+
.get<IssueSuggestionContextLevel>('issueSuggestion.contextLevel');
17+
18+
return config || IssueSuggestionContextLevel.CodeContext;
19+
}
20+
21+
static async getSuggestionAvailable(): Promise<boolean> {
22+
return FeatureFlagClient.checkGate(Features.EnableAiSuggestions) && (await findCloudSiteWithApiKey()) !== null;
23+
}
24+
25+
static async buildSettings(): Promise<IssueSuggestionSettings> {
26+
const isSuggestionEnabled = this.getSuggestionEnabled();
27+
const contextLevel = this.getSuggestionContextLevel();
28+
const isSuggestionAvailable = await this.getSuggestionAvailable();
29+
30+
return {
31+
isAvailable: isSuggestionAvailable,
32+
isEnabled: isSuggestionEnabled,
33+
level: contextLevel,
34+
};
35+
}
36+
37+
constructor(private readonly settings: IssueSuggestionSettings) {}
38+
39+
createSuggestionPrompt(data: SimplifiedTodoIssueData, contextLevel?: IssueSuggestionContextLevel): string {
40+
if (!contextLevel) {
41+
throw new Error('Context level is not defined');
42+
}
43+
44+
switch (contextLevel) {
45+
case IssueSuggestionContextLevel.TodoOnly: {
46+
return `Create a Jira issue based on the following TODO comment:\n\n${data.summary}`;
47+
}
48+
case IssueSuggestionContextLevel.CodeContext: {
49+
return `Create a Jira issue based on the following TODO comment:\n\n${data.summary}. The code context in which it appears is:\n\n${data.context}`;
50+
}
51+
default:
52+
throw new Error(`Unknown context level: ${contextLevel}`);
53+
}
54+
}
55+
56+
getSuggestionSettings(): IssueSuggestionSettings {
57+
return this.settings;
58+
}
59+
60+
async generateIssueSuggestion(data: SimplifiedTodoIssueData) {
61+
const prompt = this.createSuggestionPrompt(data, this.settings.level);
62+
try {
63+
const response = await fetchIssueSuggestions(prompt);
64+
const issue = response.suggestedIssues[0];
65+
if (!issue) {
66+
return {
67+
summary: '',
68+
description: '',
69+
error: 'Unable to fetch issue suggestions. Sorry!',
70+
};
71+
}
72+
return {
73+
summary: issue.fieldValues.summary,
74+
description: issue.fieldValues.description,
75+
error: '',
76+
};
77+
} catch (error) {
78+
console.error('Error fetching issue suggestions:', error);
79+
window.showErrorMessage('Error fetching issue suggestions: ' + error.message);
80+
return {
81+
summary: '',
82+
description: '',
83+
error: 'Error fetching issue suggestions: ' + error.message,
84+
};
85+
}
86+
}
87+
88+
async generateDummyIssueSuggestion(data: SimplifiedTodoIssueData) {
89+
return {
90+
summary: data.summary,
91+
description: `File: ${data.uri}\nLine: ${data.position.line}`,
92+
};
93+
}
94+
95+
async generate(data: SimplifiedTodoIssueData) {
96+
return this.settings.isEnabled ? this.generateIssueSuggestion(data) : this.generateDummyIssueSuggestion(data);
97+
}
98+
99+
async sendFeedback(isPositive: boolean, data: SimplifiedTodoIssueData) {
100+
const feedback = isPositive
101+
? `Positive feedback for issue suggestion: ${data.summary}`
102+
: `Negative feedback for issue suggestion: ${data.summary}`;
103+
console.log('Sending feedback:', feedback);
104+
try {
105+
// TODO: actually send an analytics event
106+
window.showInformationMessage(`Thank you for your feedback!`);
107+
} catch (error) {
108+
console.error('Error sending feedback:', error);
109+
window.showErrorMessage('Error sending feedback: ' + error.message);
110+
}
111+
}
112+
}

0 commit comments

Comments
 (0)