Skip to content

AXON-379: Atlassian Notifications - Bitbucket and Jira Comments #411

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

Merged
merged 44 commits into from
Jun 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
b9ecbed
Skeleton for atlassian notification. Need to rework notification mana…
bwieger-atlassian-com May 15, 2025
2416f02
Refactor the notification manager for better
bwieger-atlassian-com May 15, 2025
269d818
badge delegate refactor
bwieger-atlassian-com May 15, 2025
598a731
Notification manager now handles window state, auth changes, and conf…
bwieger-atlassian-com May 16, 2025
406718b
Saving some changes before using some AI
bwieger-atlassian-com May 16, 2025
27a4a54
Merge branch 'main' of https://github.com/atlassian/atlascode into AX…
bwieger-atlassian-com May 16, 2025
c78e3f0
making a pr to see changes easily
bwieger-atlassian-com May 16, 2025
ba72671
.
bwieger-atlassian-com May 16, 2025
724045a
Proof that unseen notifications works
bwieger-atlassian-com May 16, 2025
c972533
Graphql for get notification feed is working
bwieger-atlassian-com May 16, 2025
9c18749
letting ai take the wheel
bwieger-atlassian-com May 27, 2025
3c4e715
Store changes. Doing release
bwieger-atlassian-com May 27, 2025
f3e1da3
Adding badges works decently
bwieger-atlassian-com May 28, 2025
f488035
Pull requests can now clear their badges
bwieger-atlassian-com May 28, 2025
25c18d2
Merge branch 'main' of https://github.com/atlassian/atlascode into AX…
bwieger-atlassian-com May 28, 2025
e190bcd
The banner now link to prs and jiras
bwieger-atlassian-com May 28, 2025
c838ba3
Only notification within the last week are kept
bwieger-atlassian-com May 28, 2025
06f60d1
Notification can be marked as read between VS Code sessions
bwieger-atlassian-com May 29, 2025
9aa8fba
encapsulate notficationDB
bwieger-atlassian-com May 29, 2025
f226d81
Hand some stuff to christian
bwieger-atlassian-com May 29, 2025
429d5e2
badges disappear when logging out
bwieger-atlassian-com May 29, 2025
d18c60f
Notifications show previews of the message
bwieger-atlassian-com May 29, 2025
f68091e
simplified a function
bwieger-atlassian-com May 29, 2025
29f4fc0
AXON-379: issueKey or prKey for button text
cabella-dot May 30, 2025
01dfd6d
ff
bwieger-atlassian-com May 30, 2025
22e4022
Merge branch 'main' of https://github.com/atlassian/atlascode into AX…
bwieger-atlassian-com May 30, 2025
80aecac
exp flags & fix to PR number discovery
bwieger-atlassian-com May 31, 2025
87dc5f2
AXON-379: badge color + banner action analytics
cabella-dot May 31, 2025
6c1c616
test pass
bwieger-atlassian-com May 31, 2025
e54dcca
Merge branch 'AXON-379-atlassian-notifications' of https://github.com…
bwieger-atlassian-com May 31, 2025
2c44f94
Update atlassianNotificationNotifier.ts
bwieger-atlassian-com May 31, 2025
bc9d4d5
bug fix
bwieger-atlassian-com May 31, 2025
bc58943
If BB is not authed and notification is click, auth screen will show
bwieger-atlassian-com Jun 2, 2025
c10d8e4
address PR comments
bwieger-atlassian-com Jun 2, 2025
45a3b25
small refactor
bwieger-atlassian-com Jun 2, 2025
c444ce9
PR Comments
bwieger-atlassian-com Jun 2, 2025
c01648c
done merging
bwieger-atlassian-com Jun 2, 2025
52bd056
fix some compilation
bwieger-atlassian-com Jun 2, 2025
ecc1e00
Fix tests
bwieger-atlassian-com Jun 2, 2025
1e673ec
pause on making more test
bwieger-atlassian-com Jun 2, 2025
3fe3f31
Merge branch 'main' of https://github.com/atlassian/atlascode into AX…
bwieger-atlassian-com Jun 5, 2025
72bfc0c
Some TTL work
bwieger-atlassian-com Jun 6, 2025
97a9f84
PR Comments
bwieger-atlassian-com Jun 6, 2025
6e43a67
Merge branch 'main' of https://github.com/atlassian/atlascode into AX…
bwieger-atlassian-com Jun 6, 2025
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
42 changes: 42 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1298,6 +1298,7 @@
"flatten-anything": "^4.0.2",
"form-data": "^2.5.2",
"git-url-parse": "^15.0.0",
"graphql-request": "^6",
"jwt-decode": "^4.0.0",
"keytar": "^7.9.0",
"lodash": "^4.17.21",
Expand Down
6 changes: 3 additions & 3 deletions scripts/globalstore.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ case `uname -s` in
*) CODEPATH=~/.config/Code;;
esac

OLDSTATE=`sqlite3 ${CODEPATH}/User/globalStorage/state.vscdb 'select value from ItemTable where key = "atlassian.atlascode";'`
OLDSTATE=`sqlite3 "${CODEPATH}/User/globalStorage/state.vscdb" 'select value from ItemTable where key = "atlassian.atlascode";'`

if [ -z `command -v jq` ]
then
Expand Down Expand Up @@ -54,9 +54,9 @@ then
echo "Previous contents of global store $OLDSTATE"

NEWSTATE=`echo $OLDSTATE | jq -c "del(.$2)"`
sqlite3 ${CODEPATH}/User/globalStorage/state.vscdb "UPDATE ItemTable SET value = '$NEWSTATE' WHERE key = \"atlassian.atlascode\";"
sqlite3 "${CODEPATH}/User/globalStorage/state.vscdb" "UPDATE ItemTable SET value = '$NEWSTATE' WHERE key = \"atlassian.atlascode\";"

ACTUALNEWSTATE=`sqlite3 ${CODEPATH}/User/globalStorage/state.vscdb 'select value from ItemTable where key = "atlassian.atlascode";' | jq '.'`
ACTUALNEWSTATE=`sqlite3 "${CODEPATH}/User/globalStorage/state.vscdb" 'select value from ItemTable where key = "atlassian.atlascode";' | jq '.'`

echo "New contents of global store $ACTUALNEWSTATE"
fi
Expand Down
27 changes: 26 additions & 1 deletion src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CreatePrTerminalSelection, UIErrorInfo } from './analyticsTypes';
import { DetailedSiteInfo, isEmptySiteInfo, Product, ProductJira, SiteInfo } from './atlclients/authInfo';
import { BitbucketIssuesTreeViewId, PullRequestTreeViewId } from './constants';
import { Container } from './container';
import { NotificationSurface } from './views/notifications/notificationManager';
import { NotificationSurface, NotificationType } from './views/notifications/notificationManager';

// IMPORTANT
// Make sure there is a corresponding event with the correct attributes in the Data Portal for any event created here.
Expand Down Expand Up @@ -724,6 +724,31 @@ export async function createPrTerminalLinkPanelButtonClickedEvent(

return anyUserOrAnonymous<UIEvent>(e);
}

export async function notificationActionButtonClickedEvent(
uri: Uri,
notificationData: { surface: NotificationSurface; type: NotificationType },
action: string,
): Promise<UIEvent> {
const e = {
tenantIdType: null,
uiEvent: {
origin: 'desktop',
platform: AnalyticsPlatform.for(process.platform),
action: 'clicked',
actionSubject: 'button',
actionSubjectId: 'notificationActionButton',
attributes: {
uri: uri.toString(),
action: action,
notificationSurface: notificationData.surface,
notificationType: notificationData.type,
},
},
};

return anyUserOrAnonymous<UIEvent>(e);
}
// Helper methods

async function instanceTrackEvent(
Expand Down
1 change: 1 addition & 0 deletions src/atlclients/authInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface RemoveAuthInfoEvent extends AuthInfoEvent {
type: AuthChangeType.Remove;
product: Product;
credentialId: string;
userId: string;
}

export interface Product {
Expand Down
1 change: 1 addition & 0 deletions src/atlclients/authStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ describe('CredentialManager', () => {
type: AuthChangeType.Remove,
product: mockJiraSite.product,
credentialId: mockJiraSite.credentialId,
userId: mockAuthInfo.user.id,
});

// Verify info message was shown
Expand Down
15 changes: 15 additions & 0 deletions src/atlclients/authStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ export class CredentialManager implements Disposable {
return this.getAuthInfoForProductAndCredentialId(site, allowCache);
}

public async getAllValidAuthInfo(product: Product): Promise<AuthInfo[]> {
// Get all unique sites by credentialId
const sites = Container.siteManager.getSitesAvailable(product);
const uniquelyCredentialedSites = Array.from(new Map(sites.map((site) => [site.credentialId, site])).values());

const authInfos = await Promise.all(uniquelyCredentialedSites.map((site) => this.getAuthInfo(site, true)));

return authInfos.filter(
(authInfo): authInfo is AuthInfo => !!authInfo && authInfo.state !== AuthInfoState.Invalid,
);
}

/**
* Saves the auth info to both the in-memory store and the secretstorage.
*/
Expand Down Expand Up @@ -348,7 +360,9 @@ export class CredentialManager implements Disposable {
const productAuths = this._memStore.get(site.product.key);
let wasKeyDeleted = false;
let wasMemDeleted = false;
let userId = '';
if (productAuths) {
userId = productAuths.get(site.credentialId)?.user.id || '';
wasMemDeleted = productAuths.delete(site.credentialId);
this._memStore.set(site.product.key, productAuths);
}
Expand All @@ -366,6 +380,7 @@ export class CredentialManager implements Disposable {
type: AuthChangeType.Remove,
product: site.product,
credentialId: site.credentialId,
userId: userId,
};
this._onDidAuthChange.fire(removeEvent);

Expand Down
48 changes: 48 additions & 0 deletions src/atlclients/graphql/graphqlClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { request } from 'graphql-request';

import { Logger } from '../../logger';
import { AuthInfo, isOAuthInfo } from '../authInfo';

export async function graphqlRequest<T = any>(
document: string,
variables: Record<string, any>,
authInfo: AuthInfo,
endpoint: string = 'https://api.atlassian.com/graphql',
): Promise<T> {
if (!document) {
throw new Error('GraphQL document is not set.');
}
if (!authInfo) {
throw new Error('Auth info is not set.');
}

Logger.debug('GraphQL request', {
endpoint,
document,
variables,
});

try {
const response = await request<T>(endpoint, document, variables, createHeaders(authInfo));
Logger.debug('GraphQL response', { response });
return response;
} catch (error) {
Logger.error(error, 'GraphQL request failed');
throw error;
}
}

function createHeaders(authInfo: AuthInfo) {
const headers: Record<string, string> = {};
headers['Content-Type'] = 'application/json';
setAuthorizationHeader(authInfo, headers);
return headers;
}

function setAuthorizationHeader(authInfo: AuthInfo, headers: Record<string, string>) {
if (isOAuthInfo(authInfo)) {
headers['Authorization'] = `Bearer ${authInfo.access}`;
} else {
throw new Error('Unsupported authentication type.');
}
}
38 changes: 38 additions & 0 deletions src/atlclients/graphql/graphqlDocuments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { gql } from 'graphql-request';

export const unseenNotificationCountVSCode = gql`
query unseenNotificationCountVSCode {
notifications {
unseenNotificationCount
}
}
`;

export const notificationFeedVSCode = gql`
query notificationFeedVSCode($first: Int) {
notifications {
notificationFeed(filter: { readStateFilter: unread, categoryFilter: direct }, flat: true, first: $first) {
nodes {
headNotification {
notificationId
timestamp
content {
actor {
displayName
}
bodyItems {
document {
format
data
}
}
url
type
message
}
}
}
}
}
}
`;
4 changes: 3 additions & 1 deletion src/bitbucket/checkoutHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,12 @@ export class BitbucketCheckoutHelper implements CheckoutHelper {
...pr,
workspaceRepo: wsRepo,
});
} catch {
} catch (e) {
Logger.error(e, 'Error opening pull request');
this.showLoginMessage(
'Cannot open pull request. Authenticate with Bitbucket in the extension settings and try again.',
);
commands.executeCommand(Commands.ShowBitbucketAuth);
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { rerunPipeline } from './commands/bitbucket/rerunPipeline';
import { runPipeline } from './commands/bitbucket/runPipeline';
import { assignIssue } from './commands/jira/assignIssue';
import { createIssue } from './commands/jira/createIssue';
import { showIssue, showIssueForKey, showIssueForSiteIdAndKey } from './commands/jira/showIssue';
import { showIssue, showIssueForKey, showIssueForSiteIdAndKey, showIssueForURL } from './commands/jira/showIssue';
import { startWorkOnIssue } from './commands/jira/startWorkOnIssue';
import { configuration } from './config/configuration';
import { Commands, HelpTreeViewId } from './constants';
Expand Down Expand Up @@ -117,6 +117,7 @@ export function registerCommands(vscodeContext: ExtensionContext) {
Commands.ShowIssueForSiteIdAndKey,
async (siteId: string, issueKey: string) => await showIssueForSiteIdAndKey(siteId, issueKey),
),
commands.registerCommand(Commands.ShowIssueForURL, async (issueURL: string) => await showIssueForURL(issueURL)),
commands.registerCommand(Commands.ToDoIssue, (issueNode) =>
commands.executeCommand(Commands.ShowIssue, issueNode.issue),
),
Expand Down
4 changes: 2 additions & 2 deletions src/commands/bitbucket/pullRequest.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { CheckoutHelper } from 'src/bitbucket/interfaces';
import { Uri } from 'vscode';

const extractPullRequestComponents = (url: string): { repoUrl: string; prId: number } => {
export const extractPullRequestComponents = (url: string): { repoUrl: string; prId: number } => {
const repoUrl = url.slice(0, url.indexOf('/pull-requests'));
const prUrlPath = Uri.parse(url).path;
const prId = prUrlPath.slice(prUrlPath.lastIndexOf('/') + 1);
const prId = prUrlPath.split('/pull-requests/')[1]?.split('/')[0];
return { repoUrl, prId: parseInt(prId) };
};

Expand Down
26 changes: 26 additions & 0 deletions src/commands/jira/showIssue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { DetailedSiteInfo, emptySiteInfo, ProductJira } from '../../atlclients/a
import { Container } from '../../container';
import { getCachedOrFetchMinimalIssue } from '../../jira/fetchIssue';
import { issueForKey } from '../../jira/issueForKey';
import { Logger } from '../../logger';

export async function showIssue(issueOrKeyAndSite: MinimalIssueOrKeyAndSite<DetailedSiteInfo>) {
let issue: MinimalIssue<DetailedSiteInfo>;
Expand Down Expand Up @@ -41,6 +42,31 @@ export async function showIssue(issueOrKeyAndSite: MinimalIssueOrKeyAndSite<Deta
Container.jiraIssueViewManager.createOrShow(issue);
}

export async function showIssueForURL(issueURL: string) {
try {
const url = new URL(issueURL);
const hostname = url.hostname;
// Match something like /AXON-123 in the path
const match = url.pathname.match(/\/([A-Z][A-Z0-9]+-\d+)/i);
const issueKey = match ? match[1] : undefined;

if (!issueKey) {
throw new Error('Issue key not found in URL');
}

// Try to find the site by hostname
const site = Container.siteManager.getSiteForHostname(ProductJira, hostname);

if (!site) {
throw new Error(`No site found for hostname: ${hostname}`);
}

await showIssueForSiteIdAndKey(site.id, issueKey);
} catch (e) {
vscode.window.showErrorMessage('Invalid URL.');
Logger.error(e, 'Could not show issue for URL');
}
}
export async function showIssueForSiteIdAndKey(siteId: string, issueKey: string) {
const site: DetailedSiteInfo | undefined = Container.siteManager.getSiteForId(ProductJira, siteId);

Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const enum Commands {
ShowIssue = 'atlascode.jira.showIssue',
ShowIssueForKey = 'atlascode.jira.showIssueForKey',
ShowIssueForSiteIdAndKey = 'atlascode.jira.showIssueForSiteIdAndKey',
ShowIssueForURL = 'atlascode.jira.showIssueForURL',
ShowConfigPage = 'atlascode.showConfigPage',
ShowConfigPageFromExtensionContext = 'atlascode.extensionContext.showConfigPage',
ShowJiraAuth = 'atlascode.showJiraAuth',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defaultActionGuard } from '@atlassianlabs/guipi-core-controller';
import { MinimalIssue } from '@atlassianlabs/jira-pi-common-models';
import Axios from 'axios';
import { Uri } from 'vscode';

import { DetailedSiteInfo } from '../../../../atlclients/authInfo';
import {
Expand All @@ -14,6 +15,7 @@ import {
Task,
User,
} from '../../../../bitbucket/model';
import { NotificationManagerImpl } from '../../../../views/notifications/notificationManager';
import { AnalyticsApi } from '../../../analyticsApi';
import { CommonAction, CommonActionType } from '../../../ipc/fromUI/common';
import { PullRequestDetailsAction, PullRequestDetailsActionType } from '../../../ipc/fromUI/pullRequestDetails';
Expand Down Expand Up @@ -63,7 +65,9 @@ export class PullRequestDetailsWebviewController implements WebviewController<Pu
this.commonHandler = commonHandler;
}

public onShown(): void {}
public onShown(): void {
NotificationManagerImpl.getInstance().clearNotificationsByUri(Uri.parse(this.pr.data.url));
}

private postMessage(message: PullRequestDetailsMessage | PullRequestDetailsResponse | CommonMessage) {
this.messagePoster(message);
Expand Down
Loading