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.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: [
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) {