@shipfox/api-integration-jira 11.0.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +34 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +131 -0
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/config.d.ts +0 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +1 -4
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/agent-tools-provider.d.ts +26 -0
  13. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  14. package/dist/core/agent-tools-provider.js +130 -0
  15. package/dist/core/agent-tools-provider.js.map +1 -0
  16. package/dist/core/agent-tools.d.ts +16 -0
  17. package/dist/core/agent-tools.d.ts.map +1 -0
  18. package/dist/core/agent-tools.js +441 -0
  19. package/dist/core/agent-tools.js.map +1 -0
  20. package/dist/core/install.d.ts +22 -0
  21. package/dist/core/install.d.ts.map +1 -1
  22. package/dist/core/install.js +68 -20
  23. package/dist/core/install.js.map +1 -1
  24. package/dist/core/tokens.d.ts +2 -0
  25. package/dist/core/tokens.d.ts.map +1 -1
  26. package/dist/core/tokens.js +129 -37
  27. package/dist/core/tokens.js.map +1 -1
  28. package/dist/core/webhook-processor.d.ts +15 -0
  29. package/dist/core/webhook-processor.d.ts.map +1 -0
  30. package/dist/core/webhook-processor.js +137 -0
  31. package/dist/core/webhook-processor.js.map +1 -0
  32. package/dist/core/webhook-registration.d.ts +26 -0
  33. package/dist/core/webhook-registration.d.ts.map +1 -0
  34. package/dist/core/webhook-registration.js +165 -0
  35. package/dist/core/webhook-registration.js.map +1 -0
  36. package/dist/core/webhook.d.ts +18 -0
  37. package/dist/core/webhook.d.ts.map +1 -0
  38. package/dist/core/webhook.js +38 -0
  39. package/dist/core/webhook.js.map +1 -0
  40. package/dist/db/db.d.ts +68 -0
  41. package/dist/db/db.d.ts.map +1 -1
  42. package/dist/db/installations.d.ts +33 -6
  43. package/dist/db/installations.d.ts.map +1 -1
  44. package/dist/db/installations.js +79 -7
  45. package/dist/db/installations.js.map +1 -1
  46. package/dist/db/schema/installations.d.ts +34 -0
  47. package/dist/db/schema/installations.d.ts.map +1 -1
  48. package/dist/db/schema/installations.js +12 -2
  49. package/dist/db/schema/installations.js.map +1 -1
  50. package/dist/index.d.ts +34 -7
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +76 -8
  53. package/dist/index.js.map +1 -1
  54. package/dist/presentation/routes/install.d.ts +22 -0
  55. package/dist/presentation/routes/install.d.ts.map +1 -1
  56. package/dist/presentation/routes/install.js.map +1 -1
  57. package/dist/presentation/routes/webhooks.d.ts +8 -0
  58. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  59. package/dist/presentation/routes/webhooks.js +108 -0
  60. package/dist/presentation/routes/webhooks.js.map +1 -0
  61. package/dist/temporal/activities/index.d.ts +2 -0
  62. package/dist/temporal/activities/index.d.ts.map +1 -0
  63. package/dist/temporal/activities/index.js +3 -0
  64. package/dist/temporal/activities/index.js.map +1 -0
  65. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  66. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  67. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  68. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  69. package/dist/temporal/constants.d.ts +6 -0
  70. package/dist/temporal/constants.d.ts.map +1 -0
  71. package/dist/temporal/constants.js +8 -0
  72. package/dist/temporal/constants.js.map +1 -0
  73. package/dist/temporal/worker.d.ts +9 -0
  74. package/dist/temporal/worker.d.ts.map +1 -0
  75. package/dist/temporal/worker.js +26 -0
  76. package/dist/temporal/worker.js.map +1 -0
  77. package/dist/temporal/workflows/index.bundle.js +25225 -0
  78. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  79. package/dist/temporal/workflows/index.d.ts +2 -0
  80. package/dist/temporal/workflows/index.d.ts.map +1 -0
  81. package/dist/temporal/workflows/index.js +3 -0
  82. package/dist/temporal/workflows/index.js.map +1 -0
  83. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  84. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  85. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  86. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  87. package/dist/tsconfig.test.tsbuildinfo +1 -1
  88. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  89. package/drizzle/meta/0001_snapshot.json +245 -0
  90. package/drizzle/meta/_journal.json +7 -0
  91. package/package.json +17 -8
  92. package/src/api/client.test.ts +236 -9
  93. package/src/api/client.ts +192 -0
  94. package/src/config.test.ts +0 -1
  95. package/src/config.ts +1 -4
  96. package/src/core/agent-tools-provider.test.ts +299 -0
  97. package/src/core/agent-tools-provider.ts +175 -0
  98. package/src/core/agent-tools.ts +450 -0
  99. package/src/core/install.test.ts +179 -0
  100. package/src/core/install.ts +85 -19
  101. package/src/core/tokens-refresh.test.ts +223 -4
  102. package/src/core/tokens.test.ts +1 -1
  103. package/src/core/tokens.ts +153 -33
  104. package/src/core/webhook-processor.test.ts +418 -0
  105. package/src/core/webhook-processor.ts +166 -0
  106. package/src/core/webhook-registration.test.ts +375 -0
  107. package/src/core/webhook-registration.ts +216 -0
  108. package/src/core/webhook.ts +69 -0
  109. package/src/db/installations.test.ts +148 -2
  110. package/src/db/installations.ts +133 -7
  111. package/src/db/schema/installations.ts +21 -1
  112. package/src/index.test.ts +42 -0
  113. package/src/index.ts +148 -5
  114. package/src/presentation/routes/install.ts +12 -0
  115. package/src/presentation/routes/webhooks.test.ts +132 -0
  116. package/src/presentation/routes/webhooks.ts +115 -0
  117. package/src/temporal/activities/index.ts +5 -0
  118. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  119. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  120. package/src/temporal/constants.ts +8 -0
  121. package/src/temporal/worker.ts +39 -0
  122. package/src/temporal/workflows/index.ts +1 -0
  123. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  124. package/test/env.ts +0 -1
  125. package/test/factories/jira-installation.ts +4 -0
  126. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,156 @@
1
+ import type {JiraInstallation} from '#db/installations.js';
2
+ import {JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS} from '#temporal/constants.js';
3
+ import {createJiraMaintenanceActivities} from './index.js';
4
+
5
+ function installation(connectionId: string): JiraInstallation {
6
+ const now = new Date('2026-01-01T00:00:00.000Z');
7
+ return {
8
+ id: crypto.randomUUID(),
9
+ connectionId,
10
+ cloudId: crypto.randomUUID(),
11
+ siteUrl: 'https://acme.atlassian.net',
12
+ siteName: 'Acme',
13
+ authorizingAccountId: crypto.randomUUID(),
14
+ scopes: ['offline_access'],
15
+ webhookIds: [],
16
+ webhookExpiresAt: null,
17
+ status: 'installed',
18
+ tokenExpiresAt: now,
19
+ refreshTokenLastUsedAt: now,
20
+ refreshTokenLastAttemptedAt: now,
21
+ createdAt: now,
22
+ updatedAt: now,
23
+ };
24
+ }
25
+
26
+ describe('Jira proactive token refresh activity', () => {
27
+ it('force-refreshes due active connections and skips inactive ones', async () => {
28
+ const activeConnectionId = crypto.randomUUID();
29
+ const inactiveConnectionId = crypto.randomUUID();
30
+ const failedConnectionId = crypto.randomUUID();
31
+ const installations = [
32
+ installation(activeConnectionId),
33
+ installation(inactiveConnectionId),
34
+ installation(failedConnectionId),
35
+ ];
36
+ const listInstallations = vi.fn().mockResolvedValue(installations);
37
+ const resolveConnection = vi
38
+ .fn()
39
+ .mockImplementation(async (connectionId: string) =>
40
+ connectionId === activeConnectionId
41
+ ? {lifecycleStatus: 'active'}
42
+ : connectionId === failedConnectionId
43
+ ? {lifecycleStatus: 'active'}
44
+ : {lifecycleStatus: 'error'},
45
+ );
46
+ const getAccessToken = vi.fn().mockImplementation(({connectionId}: {connectionId: string}) => {
47
+ if (connectionId === failedConnectionId) throw new Error('refresh failed');
48
+ return 'fresh-access-token';
49
+ });
50
+ const heartbeat = vi.fn();
51
+ const markAttempted = vi.fn().mockResolvedValue(undefined);
52
+ const now = new Date('2026-08-04T00:00:00.000Z');
53
+ const activities = createJiraMaintenanceActivities({
54
+ tokenStore: {getAccessToken},
55
+ resolveConnection,
56
+ listInstallations,
57
+ markAttempted,
58
+ now: () => now,
59
+ heartbeat,
60
+ });
61
+
62
+ const result = await activities.refreshJiraTokensActivity();
63
+
64
+ expect(listInstallations).toHaveBeenCalledWith({
65
+ before: new Date(now.getTime() - JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS),
66
+ limit: 20,
67
+ });
68
+ expect(getAccessToken).toHaveBeenCalledWith({
69
+ connectionId: activeConnectionId,
70
+ forceRefresh: true,
71
+ });
72
+ expect(getAccessToken).toHaveBeenCalledTimes(2);
73
+ expect(heartbeat).toHaveBeenCalledTimes(6);
74
+ expect(markAttempted).toHaveBeenCalledTimes(3);
75
+ expect(result).toEqual({refreshed: 1, skipped: 1, failed: 1});
76
+ });
77
+
78
+ it('heartbeats while a token refresh is in flight', async () => {
79
+ vi.useFakeTimers();
80
+ try {
81
+ const connectionId = crypto.randomUUID();
82
+ let resolveToken!: (value: string | PromiseLike<string>) => void;
83
+ const getAccessToken = vi.fn(
84
+ () =>
85
+ new Promise<string>((resolve) => {
86
+ resolveToken = resolve;
87
+ }),
88
+ );
89
+ const heartbeat = vi.fn();
90
+ const activities = createJiraMaintenanceActivities({
91
+ tokenStore: {getAccessToken},
92
+ resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
93
+ listInstallations: vi.fn().mockResolvedValue([installation(connectionId)]),
94
+ markAttempted: vi.fn().mockResolvedValue(undefined),
95
+ heartbeat,
96
+ });
97
+
98
+ const result = activities.refreshJiraTokensActivity();
99
+ await vi.waitFor(() => expect(getAccessToken).toHaveBeenCalledOnce());
100
+ const heartbeatCountBeforeRefresh = heartbeat.mock.calls.length;
101
+
102
+ await vi.advanceTimersByTimeAsync(30_000);
103
+
104
+ expect(heartbeat.mock.calls.length).toBeGreaterThan(heartbeatCountBeforeRefresh);
105
+ resolveToken('fresh-access-token');
106
+ await expect(result).resolves.toEqual({refreshed: 1, skipped: 0, failed: 0});
107
+ } finally {
108
+ vi.useRealTimers();
109
+ }
110
+ });
111
+
112
+ it('counts an attempt failure and continues with the next installation', async () => {
113
+ const failedConnectionId = crypto.randomUUID();
114
+ const activeConnectionId = crypto.randomUUID();
115
+ const installations = [installation(failedConnectionId), installation(activeConnectionId)];
116
+ const activities = createJiraMaintenanceActivities({
117
+ tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
118
+ resolveConnection: vi.fn().mockResolvedValue({lifecycleStatus: 'active'}),
119
+ listInstallations: vi.fn().mockResolvedValue(installations),
120
+ markAttempted: vi
121
+ .fn()
122
+ .mockRejectedValueOnce(new Error('attempt write failed'))
123
+ .mockResolvedValue(undefined),
124
+ heartbeat: vi.fn(),
125
+ });
126
+
127
+ const result = await activities.refreshJiraTokensActivity();
128
+
129
+ expect(result).toEqual({refreshed: 1, skipped: 0, failed: 1});
130
+ });
131
+
132
+ it('counts a connection lookup failure and continues with the next installation', async () => {
133
+ const failedConnectionId = crypto.randomUUID();
134
+ const activeConnectionId = crypto.randomUUID();
135
+ const getAccessToken = vi.fn().mockResolvedValue('fresh-access-token');
136
+ const activities = createJiraMaintenanceActivities({
137
+ tokenStore: {getAccessToken},
138
+ resolveConnection: vi
139
+ .fn()
140
+ .mockRejectedValueOnce(new Error('connection lookup failed'))
141
+ .mockResolvedValue({lifecycleStatus: 'active'}),
142
+ listInstallations: vi
143
+ .fn()
144
+ .mockResolvedValue([installation(failedConnectionId), installation(activeConnectionId)]),
145
+ markAttempted: vi.fn().mockResolvedValue(undefined),
146
+ });
147
+
148
+ const result = await activities.refreshJiraTokensActivity();
149
+
150
+ expect(getAccessToken).toHaveBeenCalledWith({
151
+ connectionId: activeConnectionId,
152
+ forceRefresh: true,
153
+ });
154
+ expect(result).toEqual({refreshed: 1, skipped: 0, failed: 1});
155
+ });
156
+ });
@@ -0,0 +1,88 @@
1
+ import type {IntegrationConnection} from '@shipfox/api-integration-spi';
2
+ import {logger} from '@shipfox/node-opentelemetry';
3
+ import type {JiraTokenStore} from '#core/tokens.js';
4
+ import {
5
+ type JiraInstallation,
6
+ listJiraInstallationsDueForTokenRefresh,
7
+ markJiraInstallationTokenRefreshAttempt,
8
+ } from '#db/installations.js';
9
+ import {
10
+ JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS,
11
+ JIRA_REFRESH_TOKEN_SWEEP_BATCH_SIZE,
12
+ } from '#temporal/constants.js';
13
+
14
+ const JIRA_REFRESH_TOKEN_HEARTBEAT_INTERVAL_MS = 15_000;
15
+
16
+ export interface JiraTokenRefreshActivityResult {
17
+ refreshed: number;
18
+ skipped: number;
19
+ failed: number;
20
+ }
21
+
22
+ export interface CreateJiraMaintenanceActivitiesOptions {
23
+ tokenStore: Pick<JiraTokenStore, 'getAccessToken'>;
24
+ resolveConnection(
25
+ connectionId: string,
26
+ ): Promise<Pick<IntegrationConnection, 'lifecycleStatus'> | undefined>;
27
+ listInstallations?: typeof listJiraInstallationsDueForTokenRefresh | undefined;
28
+ markAttempted?: typeof markJiraInstallationTokenRefreshAttempt | undefined;
29
+ now?: (() => Date) | undefined;
30
+ heartbeat?: (() => void) | undefined;
31
+ }
32
+
33
+ export function createJiraMaintenanceActivities(options: CreateJiraMaintenanceActivitiesOptions) {
34
+ const listInstallations = options.listInstallations ?? listJiraInstallationsDueForTokenRefresh;
35
+ const markAttempted = options.markAttempted ?? markJiraInstallationTokenRefreshAttempt;
36
+ const now = options.now ?? (() => new Date());
37
+ const heartbeat = options.heartbeat ?? (() => undefined);
38
+
39
+ return {
40
+ async refreshJiraTokensActivity(): Promise<JiraTokenRefreshActivityResult> {
41
+ const before = new Date(now().getTime() - JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS);
42
+ const installations = await listInstallations({
43
+ before,
44
+ limit: JIRA_REFRESH_TOKEN_SWEEP_BATCH_SIZE,
45
+ });
46
+ let refreshed = 0;
47
+ let skipped = 0;
48
+ let failed = 0;
49
+
50
+ for (const installation of installations) {
51
+ const heartbeatInterval = setInterval(heartbeat, JIRA_REFRESH_TOKEN_HEARTBEAT_INTERVAL_MS);
52
+ try {
53
+ heartbeat();
54
+ await markAttempted(installation.connectionId);
55
+ const connection = await options.resolveConnection(installation.connectionId);
56
+ if (connection?.lifecycleStatus !== 'active') {
57
+ skipped += 1;
58
+ continue;
59
+ }
60
+
61
+ await options.tokenStore.getAccessToken({
62
+ connectionId: installation.connectionId,
63
+ forceRefresh: true,
64
+ });
65
+ refreshed += 1;
66
+ } catch (error) {
67
+ failed += 1;
68
+ logRefreshFailure(installation, error);
69
+ } finally {
70
+ clearInterval(heartbeatInterval);
71
+ heartbeat();
72
+ }
73
+ }
74
+
75
+ return {refreshed, skipped, failed};
76
+ },
77
+ };
78
+ }
79
+
80
+ function logRefreshFailure(installation: JiraInstallation, error: unknown): void {
81
+ logger().warn(
82
+ {
83
+ connectionId: installation.connectionId,
84
+ err: error,
85
+ },
86
+ 'Jira proactive token refresh failed',
87
+ );
88
+ }
@@ -0,0 +1,8 @@
1
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
2
+
3
+ export const JIRA_REFRESH_TOKEN_IDLE_WINDOW_MS = 90 * MS_PER_DAY;
4
+ export const JIRA_REFRESH_TOKEN_REFRESH_LEAD_TIME_MS = 14 * MS_PER_DAY;
5
+ export const JIRA_REFRESH_TOKEN_PROACTIVE_REFRESH_AFTER_MS =
6
+ JIRA_REFRESH_TOKEN_IDLE_WINDOW_MS - JIRA_REFRESH_TOKEN_REFRESH_LEAD_TIME_MS;
7
+ export const JIRA_REFRESH_TOKEN_SWEEP_BATCH_SIZE = 20;
8
+ export const JIRA_MAINTENANCE_TASK_QUEUE = 'integrations-jira-maintenance';
@@ -0,0 +1,39 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+ import type {IntegrationConnection} from '@shipfox/api-integration-spi';
4
+ import type {ModuleWorker} from '@shipfox/node-module';
5
+ import {Context} from '@temporalio/activity';
6
+ import type {JiraTokenStore} from '#core/tokens.js';
7
+ import {createJiraMaintenanceActivities} from '#temporal/activities/index.js';
8
+ import {JIRA_MAINTENANCE_TASK_QUEUE} from '#temporal/constants.js';
9
+
10
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
11
+ const workflowsPath = resolve(packageRoot, 'dist/temporal/workflows/index.js');
12
+
13
+ export interface CreateJiraMaintenanceWorkerOptions {
14
+ tokenStore: Pick<JiraTokenStore, 'getAccessToken'>;
15
+ resolveConnection(
16
+ connectionId: string,
17
+ ): Promise<Pick<IntegrationConnection, 'lifecycleStatus'> | undefined>;
18
+ }
19
+
20
+ export function createJiraMaintenanceWorker(
21
+ options: CreateJiraMaintenanceWorkerOptions,
22
+ ): ModuleWorker {
23
+ return {
24
+ taskQueue: JIRA_MAINTENANCE_TASK_QUEUE,
25
+ workflowsPath,
26
+ activities: () =>
27
+ createJiraMaintenanceActivities({
28
+ ...options,
29
+ heartbeat: () => Context.current().heartbeat(),
30
+ }),
31
+ workflows: [
32
+ {
33
+ name: 'refreshJiraTokensCron',
34
+ id: 'jira-refresh-tokens',
35
+ cronSchedule: '0 */6 * * *',
36
+ },
37
+ ],
38
+ };
39
+ }
@@ -0,0 +1 @@
1
+ export {refreshJiraTokensCron} from './refresh-jira-tokens-cron.js';
@@ -0,0 +1,19 @@
1
+ import {log, proxyActivities} from '@temporalio/workflow';
2
+ import type {createJiraMaintenanceActivities} from '../activities/index.js';
3
+
4
+ const {refreshJiraTokensActivity} = proxyActivities<
5
+ ReturnType<typeof createJiraMaintenanceActivities>
6
+ >({
7
+ startToCloseTimeout: '5 minutes',
8
+ heartbeatTimeout: '1 minute',
9
+ // A later cron run is the retry mechanism. Retrying this serial sweep in-place
10
+ // could overlap a still-running attempt after a timeout.
11
+ retry: {maximumAttempts: 1},
12
+ });
13
+
14
+ export async function refreshJiraTokensCron(): Promise<void> {
15
+ const result = await refreshJiraTokensActivity();
16
+ if (result.refreshed > 0 || result.failed > 0 || result.skipped > 0) {
17
+ log.info('Completed Jira proactive token refresh', {...result});
18
+ }
19
+ }
package/test/env.ts CHANGED
@@ -8,7 +8,6 @@ process.env.TZ = 'UTC';
8
8
  process.env.JIRA_OAUTH_CLIENT_ID = 'test-client-id';
9
9
  process.env.JIRA_OAUTH_CLIENT_SECRET = 'test-client-secret';
10
10
  process.env.JIRA_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/jira/callback';
11
- process.env.JIRA_WEBHOOK_SIGNING_SECRET = 'test-signing-secret';
12
11
  process.env.JIRA_WEBHOOK_BASE_URL = 'https://shipfox.example.com';
13
12
  process.env.JIRA_API_BASE_URL = 'http://127.0.0.1:0';
14
13
  process.env.JIRA_AUTH_BASE_URL = 'http://127.0.0.1:0';
@@ -14,6 +14,8 @@ export const jiraInstallationFactory = Factory.define<JiraInstallation>(({sequen
14
14
  webhookExpiresAt: installation.webhookExpiresAt,
15
15
  status: installation.status,
16
16
  tokenExpiresAt: installation.tokenExpiresAt,
17
+ refreshTokenLastUsedAt: installation.refreshTokenLastUsedAt,
18
+ refreshTokenLastAttemptedAt: installation.refreshTokenLastAttemptedAt,
17
19
  }),
18
20
  );
19
21
 
@@ -29,6 +31,8 @@ export const jiraInstallationFactory = Factory.define<JiraInstallation>(({sequen
29
31
  webhookExpiresAt: null,
30
32
  status: 'installed',
31
33
  tokenExpiresAt: null,
34
+ refreshTokenLastUsedAt: new Date(),
35
+ refreshTokenLastAttemptedAt: new Date(),
32
36
  createdAt: new Date(),
33
37
  updatedAt: new Date(),
34
38
  };