Skip to content

[PoC] Create issue with Jira AI #431

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
31 changes: 31 additions & 0 deletions e2e/tsconfig.e2e.json
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this file necessary?

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "../.generated",
"module": "CommonJS",
"target": "es2018",
"lib": ["ESNext", "dom"],
"alwaysStrict": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"allowJs": true,
"jsx": "react",
"moduleResolution": "node",
"rootDir": "../..",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"strictPropertyInitialization": true,
"typeRoots": ["node_modules/@types", "src/typings/"],
"paths": {
"testsutil/*": ["testsutil/*"]
}
},
"include": ["tests/**/*"]
}
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,21 @@
"type": "object",
"title": "Atlassian",
"properties": {
"atlascode.issueSuggestion.enabled": {
"type": "boolean",
"default": true,
"description": "Enables AI-assisted issue suggestion for TODO comments",
"scope": "window"
},
"atlascode.issueSuggestion.contextLevel": {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't we decide to cut this choice leaving "codeContext" as the only way?

"type": "string",
"description": "What context to use for issue suggestions - only the TODO text, surrouding code, or the entire file",
"default": "codeContext",
"enum": [
"todoOnly",
"codeContext"
]
},
"atlascode.outputLevel": {
"type": "string",
"default": "silent",
Expand Down
138 changes: 138 additions & 0 deletions src/atlclients/issueBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { AxiosInstance } from 'axios';

import { Container } from '../container';
import { getAxiosInstance } from '../jira/jira-client/providers';
import { BasicAuthInfo, DetailedSiteInfo, isBasicAuthInfo, ProductJira } from './authInfo';

export type SuggestedIssue = {
issueType: string;
fieldValues: {
summary: string;
description: string;
};
};

export type SuggestedIssuesResponse = {
suggestedIssues: SuggestedIssue[];
};

type SiteId = string;

const ASSIST_API = '/api/assist/api/ai/v2/ai-feature/jira/issue/source-type/conversation/suggestions';

export const isSiteCloudWithApiKey = async (site?: DetailedSiteInfo | SiteId): Promise<boolean> => {
const siteToCheck = typeof site === 'string' ? Container.siteManager.getSiteForId(ProductJira, site) : site;

if (!siteToCheck || !siteToCheck.host) {
return false;
}

if (!siteToCheck.host.endsWith('.atlassian.net')) {
return false;
}

const authInfo = await Container.credentialManager.getAuthInfo(siteToCheck);
if (!authInfo || !isBasicAuthInfo(authInfo)) {
return false;
}

return true;
};

export const findCloudSiteWithApiKey = async (): Promise<DetailedSiteInfo | undefined> => {
const sites = (
await Promise.all(
Container.siteManager
.getSitesAvailable(ProductJira)
.map(async (x) => ((await isSiteCloudWithApiKey(x)) ? x : undefined)),
)
).filter((x) => x !== undefined) as DetailedSiteInfo[];

// Any site is fine, just need an API key
return sites.length > 0 ? sites[0] : undefined;
};

export const fetchIssueSuggestions = async (prompt: string): Promise<SuggestedIssuesResponse> => {
const axiosInstance: AxiosInstance = getAxiosInstance();

try {
const site = await findCloudSiteWithApiKey();

if (!site) {
throw new Error('No site found with API key');
}

const authInfo = (await Container.credentialManager.getAuthInfo(site)) as BasicAuthInfo;
if (!authInfo || !isBasicAuthInfo(authInfo)) {
throw new Error('No valid auth info found for site');
}

const response = await axiosInstance.post(
`https://${site.host}/gateway` + ASSIST_API,
buildRequestBody(prompt),
{
headers: buildRequestHeaders(authInfo),
},
);
const content = response.data.ai_feature_output;

const responseData: SuggestedIssuesResponse = {
suggestedIssues: content.suggested_issues.map((issue: any) => ({
issueType: issue.issue_type,
fieldValues: {
summary: issue.field_values.Summary,
description: issue.field_values.Description,
},
})),
};

if (!responseData.suggestedIssues || responseData.suggestedIssues.length === 0) {
throw new Error('No suggested issues found');
}

return responseData;
} catch (error) {
console.error('Error fetching issue suggestions:', error);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
console.error('Error fetching issue suggestions:', error);
Logger.error(error, 'Error fetching issue suggestions');

throw error;
}
};

const buildRequestBody = (prompt: string): any => ({
ai_feature_input: {
source: 'SLACK',
locale: 'en-US',
context: {
primary_message: {
text: prompt,
sender: '',
timestamp: '',
},
},
suggested_issues_config: {
max_issues: 1,
suggested_issue_field_types: [
{
issue_type: 'Task',
fields: [
{
field_name: 'Summary',
field_type: 'Short-Text',
},
{
field_name: 'Description',
field_type: 'Paragraph',
},
],
},
],
},
},
});

const buildRequestHeaders = (authInfo: BasicAuthInfo): any => ({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can type this as Record<string, string>

'Content-Type': 'application/json;charset=UTF-8',
Accept: 'application/json;charset=UTF-8',
'X-Experience-Id': 'ai-issue-create-slack',
'X-Product': ProductJira,
Authorization: 'Basic ' + Buffer.from(`${authInfo.username}:${authInfo.password}`).toString('base64'),
});
2 changes: 1 addition & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export function registerCommands(vscodeContext: ExtensionContext) {
}
},
),
commands.registerCommand(Commands.CreateIssue, (data: any, source?: string) => createIssue(data, source)),
commands.registerCommand(Commands.CreateIssue, async (data: any, source?: string) => createIssue(data, source)),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious about this change

commands.registerCommand(
Commands.ShowIssue,
async (issueOrKeyAndSite: MinimalIssueOrKeyAndSite<DetailedSiteInfo>) => await showIssue(issueOrKeyAndSite),
Expand Down
60 changes: 50 additions & 10 deletions src/commands/jira/createIssue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,70 @@ import { Position, Range, Uri, ViewColumn, window, workspace, WorkspaceEdit } fr
import { startIssueCreationEvent } from '../../analytics';
import { ProductJira } from '../../atlclients/authInfo';
import { WorkspaceRepo } from '../../bitbucket/model';
import { SimplifiedTodoIssueData } from '../../config/model';
import { Container } from '../../container';
import { Logger } from '../../logger';
import { CommentData } from '../../webviews/createIssueWebview';
import { IssueSuggestionManager } from './issueSuggestionManager';

export interface TodoIssueData {
summary: string;
uri: Uri;
insertionPoint: Position;
context: string;
}

export function createIssue(data: Uri | TodoIssueData | undefined, source?: string) {
const simplify = (data: TodoIssueData): SimplifiedTodoIssueData => {
return {
summary: data.summary,
context: data.context,
position: {
line: data.insertionPoint.line,
character: data.insertionPoint.character,
},
uri: data.uri.toString(),
};
};

export async function createIssue(data: Uri | TodoIssueData | undefined, source?: string) {
if (isTodoIssueData(data)) {
const partialIssue = {
summary: data.summary,
description: descriptionForUri(data.uri),
uri: data.uri,
position: data.insertionPoint,
onCreated: annotateComment,
};
Container.createIssueWebview.createOrShow(ViewColumn.Beside, partialIssue);
const settings = await IssueSuggestionManager.buildSettings();
const todoData = simplify(data);

await Container.createIssueWebview.createOrShow(
ViewColumn.Beside,
{
description: descriptionForUri(data.uri),
uri: data.uri,
position: data.insertionPoint,
onCreated: annotateComment,
},
settings,
todoData,
);

try {
const suggestionManager = new IssueSuggestionManager(settings);

await suggestionManager.generate(todoData).then(async (suggestion) => {
await Container.createIssueWebview.fastUpdateFields({
summary: suggestion.summary,
description: suggestion.description,
});
});
} catch (error) {
// The view is already created with legacy logic, do nothing
Logger.error(error, 'Error generating issue suggestion settings');
}

startIssueCreationEvent('todoComment', ProductJira).then((e) => {
Container.analyticsClient.sendTrackEvent(e);
});

return;
} else if (isUri(data) && data.scheme === 'file') {
}

if (isUri(data) && data.scheme === 'file') {
Container.createIssueWebview.createOrShow(ViewColumn.Active, { description: descriptionForUri(data) });
startIssueCreationEvent('contextMenu', ProductJira).then((e) => {
Container.analyticsClient.sendTrackEvent(e);
Expand Down
Loading