From 5de66f51fa99e8dd49a7fbf0855171a6ef870ab0 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 12 Apr 2026 16:17:17 -0400 Subject: [PATCH 1/2] fix(jira): open OAuth flows in new tabs and implement auto-reload on window focus --- .../components/signal-board/signal-board.css | 4 + .../components/signal-board/signal-board.html | 2 + .../components/signal-board/signal-board.ts | 18 +++- .../workspace-integrations.css | 10 +++ .../workspace-integrations.html | 54 ++++++++++++ .../workspace-integrations.ts | 86 ++++++++++++++++++- .../components/workspace/create-workspace.ts | 12 ++- src/app/models/jira-integration.model.ts | 26 ++++++ src/app/models/signal.model.ts | 7 +- src/app/services/integration.service.ts | 65 +++++++++++++- 10 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 src/app/models/jira-integration.model.ts diff --git a/src/app/components/signal-board/signal-board.css b/src/app/components/signal-board/signal-board.css index 8394950..89749e3 100644 --- a/src/app/components/signal-board/signal-board.css +++ b/src/app/components/signal-board/signal-board.css @@ -51,6 +51,10 @@ background: #111827; } +.signal-source.jira { + background: #2563eb; +} + .signal-card h3 { margin: 0; font-size: 1.05rem; diff --git a/src/app/components/signal-board/signal-board.html b/src/app/components/signal-board/signal-board.html index 2608f1f..c6f11b3 100644 --- a/src/app/components/signal-board/signal-board.html +++ b/src/app/components/signal-board/signal-board.html @@ -16,6 +16,8 @@

{{ signal.title }}

{{ signal.metadata.state }} #{{ getSlackChannel(signal) }} + {{ signal.metadata.issueType }} + {{ signal.metadata.priority }} {{ label }}
diff --git a/src/app/components/signal-board/signal-board.ts b/src/app/components/signal-board/signal-board.ts index e96a800..a1b35f4 100644 --- a/src/app/components/signal-board/signal-board.ts +++ b/src/app/components/signal-board/signal-board.ts @@ -22,28 +22,40 @@ export class SignalBoardComponent { if (signal.sourceType === 'slack') { return 'Slack Message'; } + if (signal.sourceType === 'jira') { + return signal.metadata.issueType || 'Jira Issue'; + } return signal.metadata.type === 'pull_request' ? 'Pull Request' : 'Issue'; } getSourceLabel(signal: Signal): string { - return signal.sourceType === 'slack' ? 'Slack' : 'GitHub'; + if (signal.sourceType === 'slack') return 'Slack'; + if (signal.sourceType === 'jira') return 'Jira'; + return 'GitHub'; } getSourceClass(signal: Signal): string { - return signal.sourceType === 'slack' ? 'slack' : 'github'; + if (signal.sourceType === 'slack') return 'slack'; + if (signal.sourceType === 'jira') return 'jira'; + return 'github'; } getPrimaryContext(signal: Signal): string { if (signal.sourceType === 'slack') { return `#${this.getSlackChannel(signal)} in Slack`; } + if (signal.sourceType === 'jira') { + return `${signal.metadata.projectKey} in Jira`; + } return `${this.getTypeLabel(signal)} #${signal.metadata.number} in ${signal.metadata.repository}`; } getOpenLabel(signal: Signal): string { - return signal.sourceType === 'slack' ? 'Open in Slack' : 'Open in GitHub'; + if (signal.sourceType === 'slack') return 'Open in Slack'; + if (signal.sourceType === 'jira') return 'Open in Jira'; + return 'Open in GitHub'; } getSlackChannel(signal: Signal): string { diff --git a/src/app/components/workspace-integrations/workspace-integrations.css b/src/app/components/workspace-integrations/workspace-integrations.css index 5fe0789..8bb1394 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.css +++ b/src/app/components/workspace-integrations/workspace-integrations.css @@ -79,6 +79,11 @@ color: #9d174d; } +.provider-badge.jira-badge { + background: #e0e7ff; + color: #3730a3; +} + .card-actions, .section-head { display: flex; @@ -110,6 +115,11 @@ background: #7c3aed; } +.jira-btn { + border-color: #2563eb; + background: #2563eb; +} + .secondary-btn { border: 1px solid #cbd5e1; background: #fff; diff --git a/src/app/components/workspace-integrations/workspace-integrations.html b/src/app/components/workspace-integrations/workspace-integrations.html index 605c9df..3b8c659 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.html +++ b/src/app/components/workspace-integrations/workspace-integrations.html @@ -125,4 +125,58 @@

GitHub sync status

({{ githubSyncStatus.itemsSynced }} items)

+ + +
+
+ Jira +

Jira connection

+

+ Connected to Jira. +

+ +

Not connected yet. Start the OAuth flow to import assigned issues from Jira.

+
+
+ +
+ + + +
+
+ +

{{ jiraFeedbackMessage }}

+

{{ jiraErrorMessage }}

+ +
+
+
+

Jira Cloud Resources

+

Accessible Atlassian cloud sites.

+
+
+ +
+
+ + {{ resource.name }} + {{ resource.url }} + +
+
+
+ +
+

Jira sync status

+

Last sync: {{ jiraLastSyncAt | date: 'medium' }}

+

No sync has run yet.

+

+ Current status: + {{ jiraSyncStatus.status }} +

+
+ diff --git a/src/app/components/workspace-integrations/workspace-integrations.ts b/src/app/components/workspace-integrations/workspace-integrations.ts index 86ad40d..7762db3 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.ts +++ b/src/app/components/workspace-integrations/workspace-integrations.ts @@ -1,9 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, OnDestroy, inject } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { IntegrationService } from '../../services/integration.service'; import { GitHubRepo, SyncStatus } from '../../models/github-integration.model'; import { SlackChannel } from '../../models/slack-integration.model'; +import { AtlassianResource, JiraSyncStatus } from '../../models/jira-integration.model'; @Component({ selector: 'app-workspace-integrations', @@ -12,7 +13,7 @@ import { SlackChannel } from '../../models/slack-integration.model'; templateUrl: './workspace-integrations.html', styleUrl: './workspace-integrations.css' }) -export class WorkspaceIntegrationsComponent implements OnInit { +export class WorkspaceIntegrationsComponent implements OnInit, OnDestroy { private readonly route = inject(ActivatedRoute); private readonly integrationService = inject(IntegrationService); @@ -39,10 +40,40 @@ export class WorkspaceIntegrationsComponent implements OnInit { isGitHubSaving = false; isSyncing = false; + isJiraConnected = false; + jiraResources: AtlassianResource[] = []; + jiraLastSyncAt?: Date; + jiraSyncStatus?: JiraSyncStatus; + jiraFeedbackMessage = ''; + jiraErrorMessage = ''; + isJiraSyncing = false; + ngOnInit(): void { this.workspaceId = this.route.snapshot.paramMap.get('id') ?? ''; + this.loadAllIntegrations(); + window.addEventListener('focus', this.onWindowFocus); + } + + ngOnDestroy(): void { + window.removeEventListener('focus', this.onWindowFocus); + } + + private onWindowFocus = (): void => { + // If we have an active feedback message, let's refresh to see if connection succeeded in background + if (this.slackFeedbackMessage === 'Starting Slack connection...' || + this.githubFeedbackMessage === 'Starting GitHub connection...' || + this.jiraFeedbackMessage === 'Starting Jira connection...') { + this.slackFeedbackMessage = ''; + this.githubFeedbackMessage = ''; + this.jiraFeedbackMessage = ''; + this.loadAllIntegrations(); + } + }; + + private loadAllIntegrations(): void { this.loadSlackChannels(); this.loadRepos(); + this.loadJira(); } connectSlack(): void { @@ -82,6 +113,30 @@ export class WorkspaceIntegrationsComponent implements OnInit { }); } + connectJira(): void { + this.jiraErrorMessage = ''; + this.jiraFeedbackMessage = 'Starting Jira connection...'; + this.integrationService.connectJira(this.workspaceId).subscribe({ + error: (error: Error) => { + this.jiraErrorMessage = error.message; + this.jiraFeedbackMessage = ''; + } + }); + } + + disconnectJira(): void { + this.integrationService.disconnectJira(this.workspaceId).subscribe({ + next: () => { + this.jiraSyncStatus = undefined; + this.jiraFeedbackMessage = 'Jira integration disconnected.'; + this.loadJira(); + }, + error: (error: Error) => { + this.jiraErrorMessage = error.message; + } + }); + } + toggleSlackChannel(channelId: string, checked: boolean): void { this.selectedSlackChannelIds = checked ? [...this.selectedSlackChannelIds, channelId] @@ -155,6 +210,25 @@ export class WorkspaceIntegrationsComponent implements OnInit { }); } + syncJiraNow(): void { + this.isJiraSyncing = true; + this.jiraErrorMessage = ''; + this.integrationService.syncJira(this.workspaceId).subscribe({ + next: (status) => { + this.jiraSyncStatus = status; + this.isJiraSyncing = false; + this.jiraFeedbackMessage = status.status === 'in_progress' + ? 'Jira sync started. Signals will refresh shortly.' + : 'Jira sync could not be started.'; + this.loadJira(); + }, + error: (error: Error) => { + this.isJiraSyncing = false; + this.jiraErrorMessage = error.message; + } + }); + } + isSlackChannelSelected(channelId: string): boolean { return this.selectedSlackChannelIds.includes(channelId); } @@ -184,4 +258,12 @@ export class WorkspaceIntegrationsComponent implements OnInit { this.githubLastSyncAt = response.lastSyncAt; }); } + + private loadJira(): void { + this.integrationService.getJiraProjects(this.workspaceId).subscribe(response => { + this.isJiraConnected = response.connected; + this.jiraResources = response.resources; + this.jiraLastSyncAt = response.lastSyncAt; + }); + } } diff --git a/src/app/components/workspace/create-workspace.ts b/src/app/components/workspace/create-workspace.ts index 5d08528..bdddb7f 100644 --- a/src/app/components/workspace/create-workspace.ts +++ b/src/app/components/workspace/create-workspace.ts @@ -31,7 +31,17 @@ export class CreateWorkspace { this.error = ''; this.workspaceService.createWorkspace(trimmedName, this.description.trim()).subscribe({ - next: () => this.router.navigate(['/dashboard']), + next: () => { + this.router.navigate(['/dashboard']).then(success => { + if (!success) { + this.isSubmitting = false; + this.error = 'Navigation failed. Workspace was created, but unable to load dashboard.'; + } + }).catch(() => { + this.isSubmitting = false; + this.error = 'An error occurred while loading the dashboard.'; + }); + }, error: () => { this.error = 'Unable to create workspace. Please try again.'; this.isSubmitting = false; diff --git a/src/app/models/jira-integration.model.ts b/src/app/models/jira-integration.model.ts new file mode 100644 index 0000000..7f398bf --- /dev/null +++ b/src/app/models/jira-integration.model.ts @@ -0,0 +1,26 @@ +export interface JiraProject { + id: string; + key: string; + name: string; + avatarUrl: string; +} + +export interface AtlassianResource { + id: string; + url: string; + name: string; + scopes: string[]; + avatarUrl: string; +} + +export interface JiraSyncStatus { + status: 'idle' | 'in_progress' | 'completed' | 'error'; + lastSyncAt?: string; + message?: string; +} + +export interface JiraIntegrationResponse { + connected: boolean; + resources: AtlassianResource[]; + lastSyncAt?: string; +} diff --git a/src/app/models/signal.model.ts b/src/app/models/signal.model.ts index 529ccfb..bfc2534 100644 --- a/src/app/models/signal.model.ts +++ b/src/app/models/signal.model.ts @@ -1,4 +1,4 @@ -export type SignalSourceType = 'slack' | 'github' | 'email' | 'decision'; +export type SignalSourceType = 'slack' | 'github' | 'email' | 'decision' | 'jira'; export type SignalStatus = 'unread' | 'read' | 'archived'; export interface SignalMetadata { @@ -10,6 +10,9 @@ export interface SignalMetadata { assignees?: string[]; createdAt?: Date; updatedAt?: Date; + issueType?: string; + priority?: string; + projectKey?: string; [key: string]: unknown; } @@ -28,6 +31,6 @@ export interface Signal { } export interface SignalFilters { - source: 'all' | 'github' | 'slack' | 'decision'; + source: 'all' | 'github' | 'slack' | 'decision' | 'jira'; status: 'all' | SignalStatus; } diff --git a/src/app/services/integration.service.ts b/src/app/services/integration.service.ts index 3402ced..f6aa728 100644 --- a/src/app/services/integration.service.ts +++ b/src/app/services/integration.service.ts @@ -7,7 +7,7 @@ import { toError } from './http-error'; interface IntegrationRecord { id: number; - provider: 'slack' | 'github'; + provider: 'slack' | 'github' | 'jira'; workspace_id?: number; metadata?: string; updated_at: string; @@ -55,7 +55,7 @@ export class IntegrationService { connectSlack(workspaceId: string): Observable { return this.getSlackAuthUrl(workspaceId).pipe( map(({ authUrl }) => { - window.location.assign(authUrl); + window.open(authUrl, '_blank'); }), ); } @@ -124,7 +124,7 @@ export class IntegrationService { connectGitHub(): Observable { return this.getGitHubAuthUrl().pipe( map(({ authUrl }) => { - window.location.assign(authUrl); + window.open(authUrl, '_blank'); }), ); } @@ -182,6 +182,65 @@ export class IntegrationService { ); } + getJiraAuthUrl(workspaceId: string): Observable<{ authUrl: string }> { + const params = new HttpParams().set('workspace_id', workspaceId); + return this.http.get(`${this.apiUrl}/jira/auth`, { params }).pipe( + map((response) => ({ authUrl: response.auth_url })), + catchError((error) => throwError(() => toError(error, 'Unable to start Jira connection.'))), + ); + } + + connectJira(workspaceId: string): Observable { + return this.getJiraAuthUrl(workspaceId).pipe( + map(({ authUrl }) => { + window.open(authUrl, '_blank'); + }), + ); + } + + getJiraProjects(workspaceId: string): Observable { + return this.getIntegrations(workspaceId).pipe( + switchMap((integrations) => { + const jiraIntegration = integrations.find((integration) => integration.provider === 'jira'); + if (!jiraIntegration) { + return of({ + connected: false, + resources: [], + }); + } + + const params = new HttpParams().set('workspace_id', workspaceId); + return this.http.get(`${this.apiUrl}/jira/projects`, { params }).pipe( + map((resources) => ({ + connected: true, + resources: resources, + lastSyncAt: jiraIntegration.updated_at ? new Date(jiraIntegration.updated_at) : undefined, + })), + ); + }), + catchError((error) => throwError(() => toError(error, 'Unable to load Jira projects.'))), + ); + } + + disconnectJira(workspaceId: string): Observable { + const params = new HttpParams().set('workspace_id', workspaceId); + return this.http.delete(`${this.apiUrl}/jira`, { params }).pipe( + catchError((error) => throwError(() => toError(error, 'Unable to disconnect Jira.'))), + ); + } + + syncJira(workspaceId: string): Observable { + const params = new HttpParams().set('workspace_id', workspaceId); + return this.http.post(`${this.apiUrl}/jira/sync`, {}, { params }).pipe( + map((response) => { + return { + status: response.status === 'sync_started' ? 'in_progress' : 'failed', + }; + }), + catchError((error) => throwError(() => toError(error, 'Unable to sync Jira.'))), + ); + } + private getIntegrations(workspaceId?: string): Observable { let params = new HttpParams(); if (workspaceId) { From c7d94e10dd4e315c223864bd38c108fb96ec80f0 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 12 Apr 2026 16:28:46 -0400 Subject: [PATCH 2/2] test(jira): fix workspace integration tests by mocking Jira service methods --- .../workspace-integrations.spec.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/app/components/workspace-integrations/workspace-integrations.spec.ts b/src/app/components/workspace-integrations/workspace-integrations.spec.ts index fbfdb7f..440a8b8 100644 --- a/src/app/components/workspace-integrations/workspace-integrations.spec.ts +++ b/src/app/components/workspace-integrations/workspace-integrations.spec.ts @@ -19,7 +19,11 @@ describe('WorkspaceIntegrationsComponent', () => { 'connectGitHub', 'updateGitHubRepos', 'disconnectGitHub', - 'syncGitHub' + 'syncGitHub', + 'getJiraProjects', + 'connectJira', + 'disconnectJira', + 'syncJira' ]); mockIntegrationService.getSlackChannels.and.returnValue(of({ @@ -49,6 +53,15 @@ describe('WorkspaceIntegrationsComponent', () => { mockIntegrationService.disconnectGitHub.and.returnValue(of(void 0)); mockIntegrationService.connectGitHub.and.returnValue(of(void 0)); + mockIntegrationService.getJiraProjects.and.returnValue(of({ + connected: false, + resources: [], + lastSyncAt: undefined + })); + mockIntegrationService.connectJira.and.returnValue(of(void 0)); + mockIntegrationService.disconnectJira.and.returnValue(of(void 0)); + mockIntegrationService.syncJira.and.returnValue(of({ status: 'in_progress' })); + await TestBed.configureTestingModule({ imports: [WorkspaceIntegrationsComponent], providers: [