@sdods/integrations 0.2.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.
- package/LICENSE +17 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.js +76 -0
- package/dist/dedupe.d.ts +22 -0
- package/dist/dedupe.js +42 -0
- package/dist/github.d.ts +32 -0
- package/dist/github.js +493 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/jira.d.ts +27 -0
- package/dist/jira.js +420 -0
- package/dist/registry.d.ts +20 -0
- package/dist/registry.js +90 -0
- package/dist/secrets.d.ts +13 -0
- package/dist/secrets.js +50 -0
- package/dist/store.d.ts +18 -0
- package/dist/store.js +75 -0
- package/dist/summary.d.ts +15 -0
- package/dist/summary.js +349 -0
- package/dist/types.d.ts +154 -0
- package/dist/types.js +2 -0
- package/package.json +43 -0
package/dist/jira.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { gherkinBlock, selectIssueScreenshots, truncate } from './context.js';
|
|
4
|
+
import { IssueDedupe, fingerprintMarker, issueTitle } from './dedupe.js';
|
|
5
|
+
export class JiraProvider {
|
|
6
|
+
name = 'jira';
|
|
7
|
+
config;
|
|
8
|
+
authHeader = '';
|
|
9
|
+
base = '';
|
|
10
|
+
fetchImpl;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
13
|
+
}
|
|
14
|
+
async init(config, secrets) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
if (!config.baseUrl)
|
|
17
|
+
throw new Error('Jira integration needs baseUrl.');
|
|
18
|
+
if (!config.projectKey)
|
|
19
|
+
throw new Error('Jira integration needs projectKey.');
|
|
20
|
+
if (!secrets.token)
|
|
21
|
+
throw new Error(`Jira integration needs a token in $${config.tokenEnv}.`);
|
|
22
|
+
this.base = config.baseUrl.replace(/\/+$/, '');
|
|
23
|
+
if (config.authMode === 'bearer') {
|
|
24
|
+
this.authHeader = `Bearer ${secrets.token}`;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
if (!secrets.email)
|
|
28
|
+
throw new Error(`Jira basic auth needs an email in $${config.emailEnv}.`);
|
|
29
|
+
this.authHeader = `Basic ${Buffer.from(`${secrets.email}:${secrets.token}`).toString('base64')}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async request(method, path, body, extraHeaders = {}) {
|
|
33
|
+
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
34
|
+
const res = await this.fetchImpl(`${this.base}${path}`, {
|
|
35
|
+
method,
|
|
36
|
+
headers: {
|
|
37
|
+
Authorization: this.authHeader,
|
|
38
|
+
Accept: 'application/json',
|
|
39
|
+
...(isForm ? {} : body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
40
|
+
...extraHeaders,
|
|
41
|
+
},
|
|
42
|
+
body: isForm ? body : body !== undefined ? JSON.stringify(body) : undefined,
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const text = await res.text().catch(() => '');
|
|
46
|
+
throw new Error(`Jira ${method} ${path} → ${res.status} ${res.statusText}${text ? `: ${text.slice(0, 300)}` : ''}`);
|
|
47
|
+
}
|
|
48
|
+
if (res.status === 204)
|
|
49
|
+
return undefined;
|
|
50
|
+
const ct = res.headers.get('content-type') ?? '';
|
|
51
|
+
return (ct.includes('json') ? await res.json() : await res.text());
|
|
52
|
+
}
|
|
53
|
+
async test() {
|
|
54
|
+
try {
|
|
55
|
+
const me = await this.request('GET', '/rest/api/3/myself');
|
|
56
|
+
const project = await this.request('GET', `/rest/api/3/project/${this.config.projectKey}`);
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
detail: `authenticated as ${me.displayName ?? me.emailAddress ?? 'user'}; project ${project.key} (${project.name}) reachable`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return { ok: false, detail: e.message };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async onRunFinished(summary, ctx) {
|
|
67
|
+
const actions = [];
|
|
68
|
+
const { run } = summary;
|
|
69
|
+
const dedupe = new IssueDedupe(ctx.store);
|
|
70
|
+
for (const scenario of this.failuresToReport(summary)) {
|
|
71
|
+
let decision = await dedupe.decide(run.projectSlug, this.name, scenario.fingerprint);
|
|
72
|
+
if (decision.action === 'create' && !ctx.dryRun) {
|
|
73
|
+
// JQL fallback: an issue may exist that the local store does not know about.
|
|
74
|
+
const found = await this.findByFingerprint(scenario.fingerprint).catch(() => null);
|
|
75
|
+
if (found) {
|
|
76
|
+
const link = await ctx.store.save({
|
|
77
|
+
projectSlug: run.projectSlug,
|
|
78
|
+
provider: this.name,
|
|
79
|
+
fingerprint: scenario.fingerprint,
|
|
80
|
+
scenarioName: scenario.scenarioName,
|
|
81
|
+
externalKey: found.key,
|
|
82
|
+
externalUrl: found.url,
|
|
83
|
+
status: found.status,
|
|
84
|
+
source: 'auto',
|
|
85
|
+
lastRunId: run.id,
|
|
86
|
+
});
|
|
87
|
+
decision = { action: 'comment', link, reason: `found ${found.key} via JQL` };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (decision.action === 'create') {
|
|
91
|
+
if (ctx.dryRun) {
|
|
92
|
+
actions.push({
|
|
93
|
+
provider: this.name,
|
|
94
|
+
kind: 'issue-created',
|
|
95
|
+
fingerprint: scenario.fingerprint,
|
|
96
|
+
detail: `[dry-run] would create "${issueTitle(scenario.featureName, scenario.scenarioName, scenario.browser)}"`,
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const ref = await this.createIssue({
|
|
101
|
+
projectSlug: run.projectSlug,
|
|
102
|
+
run,
|
|
103
|
+
scenario,
|
|
104
|
+
screenshots: selectIssueScreenshots(scenario),
|
|
105
|
+
reportUrl: summary.reportUrl,
|
|
106
|
+
}, ctx);
|
|
107
|
+
actions.push({
|
|
108
|
+
provider: this.name,
|
|
109
|
+
kind: 'issue-created',
|
|
110
|
+
target: ref.key,
|
|
111
|
+
url: ref.url,
|
|
112
|
+
fingerprint: scenario.fingerprint,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
else if (decision.action === 'comment' && decision.link) {
|
|
116
|
+
const link = decision.link;
|
|
117
|
+
if (ctx.dryRun) {
|
|
118
|
+
actions.push({
|
|
119
|
+
provider: this.name,
|
|
120
|
+
kind: 'issue-commented',
|
|
121
|
+
target: link.externalKey,
|
|
122
|
+
fingerprint: scenario.fingerprint,
|
|
123
|
+
detail: '[dry-run] would comment "failed again"',
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
await this.comment(link.externalKey, [
|
|
128
|
+
paragraph(`Failed again in run ${run.id}${ctx.ci.runUrl ? ` (${ctx.ci.runUrl})` : ''}.`),
|
|
129
|
+
codeBlock(truncate(scenario.errorMessage ?? '', 1500) || 'no error message'),
|
|
130
|
+
]);
|
|
131
|
+
await ctx.store.save({ ...link, lastRunId: run.id });
|
|
132
|
+
actions.push({
|
|
133
|
+
provider: this.name,
|
|
134
|
+
kind: 'issue-commented',
|
|
135
|
+
target: link.externalKey,
|
|
136
|
+
url: link.externalUrl,
|
|
137
|
+
fingerprint: scenario.fingerprint,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
actions.push({
|
|
142
|
+
provider: this.name,
|
|
143
|
+
kind: 'skipped',
|
|
144
|
+
fingerprint: scenario.fingerprint,
|
|
145
|
+
detail: decision.reason,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (this.config.linkTaggedScenarios) {
|
|
150
|
+
for (const scenario of summary.scenarios) {
|
|
151
|
+
for (const key of scenario.jiraKeys) {
|
|
152
|
+
const existing = await ctx.store.findByKey(run.projectSlug, this.name, key);
|
|
153
|
+
if (existing)
|
|
154
|
+
continue;
|
|
155
|
+
if (ctx.dryRun) {
|
|
156
|
+
actions.push({
|
|
157
|
+
provider: this.name,
|
|
158
|
+
kind: 'skipped',
|
|
159
|
+
target: key,
|
|
160
|
+
detail: '[dry-run] would link tagged issue',
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const ref = await this.linkIssue(scenario.fingerprint, key);
|
|
166
|
+
await ctx.store.save({
|
|
167
|
+
projectSlug: run.projectSlug,
|
|
168
|
+
provider: this.name,
|
|
169
|
+
fingerprint: scenario.fingerprint,
|
|
170
|
+
scenarioName: scenario.scenarioName,
|
|
171
|
+
externalKey: ref.key,
|
|
172
|
+
externalUrl: ref.url,
|
|
173
|
+
status: ref.status,
|
|
174
|
+
source: 'tag',
|
|
175
|
+
lastRunId: run.id,
|
|
176
|
+
lastSyncedAt: new Date().toISOString(),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
ctx.logger.warn(`Cannot link ${key}: ${e.message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const scenario of summary.passed) {
|
|
186
|
+
const link = await ctx.store.findOpen(run.projectSlug, this.name, scenario.fingerprint);
|
|
187
|
+
if (!link)
|
|
188
|
+
continue;
|
|
189
|
+
const action = await this.onScenarioPassed(link, run, ctx);
|
|
190
|
+
if (action)
|
|
191
|
+
actions.push(action);
|
|
192
|
+
}
|
|
193
|
+
return { provider: this.name, actions };
|
|
194
|
+
}
|
|
195
|
+
failuresToReport(summary) {
|
|
196
|
+
const mode = this.config.createIssueOnFailure;
|
|
197
|
+
if (mode === 'never')
|
|
198
|
+
return [];
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
return summary.failed.filter((s) => {
|
|
201
|
+
if (seen.has(s.fingerprint))
|
|
202
|
+
return false;
|
|
203
|
+
seen.add(s.fingerprint);
|
|
204
|
+
return mode === 'always' || s.suiteTag === '@smoke';
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async findByFingerprint(fingerprint) {
|
|
208
|
+
const jql = `project = ${this.config.projectKey} AND labels = sdods AND text ~ "${fingerprintMarker(fingerprint)}" AND statusCategory != Done ORDER BY created DESC`;
|
|
209
|
+
const data = await this.request('POST', '/rest/api/3/search/jql', { jql, maxResults: 1, fields: ['status'] });
|
|
210
|
+
const issue = data.issues?.[0];
|
|
211
|
+
if (!issue)
|
|
212
|
+
return null;
|
|
213
|
+
return {
|
|
214
|
+
provider: this.name,
|
|
215
|
+
key: issue.key,
|
|
216
|
+
url: this.browseUrl(issue.key),
|
|
217
|
+
status: statusFromCategory(issue.fields?.status?.statusCategory?.key),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
async createIssue(input, ctx) {
|
|
221
|
+
const { scenario, run } = input;
|
|
222
|
+
const content = [
|
|
223
|
+
heading(`Scenario`, 3),
|
|
224
|
+
codeBlock(gherkinBlock(scenario), 'gherkin'),
|
|
225
|
+
heading('Error', 3),
|
|
226
|
+
codeBlock(truncate(scenario.errorMessage ?? 'no error message', 3000)),
|
|
227
|
+
heading('Context', 3),
|
|
228
|
+
bullets([
|
|
229
|
+
`Project: ${run.projectSlug} · Environment: ${run.env} · Browser: ${scenario.browser ?? scenario.layer}`,
|
|
230
|
+
`Run: ${run.id}${run.gitSha ? ` · Commit ${run.gitSha.slice(0, 10)}` : ''}`,
|
|
231
|
+
...(input.reportUrl ? [`Report: ${input.reportUrl}`] : []),
|
|
232
|
+
...(ctx.ci.runUrl ? [`CI run: ${ctx.ci.runUrl}`] : []),
|
|
233
|
+
]),
|
|
234
|
+
paragraph(fingerprintMarker(scenario.fingerprint)),
|
|
235
|
+
];
|
|
236
|
+
const created = await this.request('POST', '/rest/api/3/issue', {
|
|
237
|
+
fields: {
|
|
238
|
+
project: { key: this.config.projectKey },
|
|
239
|
+
issuetype: { name: this.config.issueType },
|
|
240
|
+
summary: issueTitle(scenario.featureName, scenario.scenarioName, scenario.browser),
|
|
241
|
+
labels: this.config.labels,
|
|
242
|
+
description: { type: 'doc', version: 1, content },
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
await this.attachScreenshots(created.key, input, ctx);
|
|
246
|
+
const ref = {
|
|
247
|
+
provider: this.name,
|
|
248
|
+
key: created.key,
|
|
249
|
+
url: this.browseUrl(created.key),
|
|
250
|
+
status: 'open',
|
|
251
|
+
};
|
|
252
|
+
await ctx.store.save({
|
|
253
|
+
projectSlug: input.projectSlug,
|
|
254
|
+
provider: this.name,
|
|
255
|
+
fingerprint: scenario.fingerprint,
|
|
256
|
+
scenarioName: scenario.scenarioName,
|
|
257
|
+
externalKey: ref.key,
|
|
258
|
+
externalUrl: ref.url,
|
|
259
|
+
status: 'open',
|
|
260
|
+
source: 'auto',
|
|
261
|
+
lastRunId: run.id,
|
|
262
|
+
lastSyncedAt: new Date().toISOString(),
|
|
263
|
+
});
|
|
264
|
+
return ref;
|
|
265
|
+
}
|
|
266
|
+
async attachScreenshots(key, input, ctx) {
|
|
267
|
+
const capBytes = this.config.maxAttachmentMb * 1024 * 1024;
|
|
268
|
+
let used = 0;
|
|
269
|
+
const files = [];
|
|
270
|
+
for (const shot of input.screenshots) {
|
|
271
|
+
const file = ctx.artifactPath(shot, input.run);
|
|
272
|
+
if (!file)
|
|
273
|
+
continue;
|
|
274
|
+
const size = statSync(file).size;
|
|
275
|
+
if (used + size > capBytes)
|
|
276
|
+
break;
|
|
277
|
+
used += size;
|
|
278
|
+
files.push({
|
|
279
|
+
path: file,
|
|
280
|
+
name: `${shot.phase ?? 'shot'}${shot.stepIndex != null ? `-step${shot.stepIndex}` : ''}-${basename(file)}`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (input.scenario.tracePath) {
|
|
284
|
+
const file = ctx.artifactPath({ relPath: input.scenario.tracePath }, input.run);
|
|
285
|
+
if (file && used + statSync(file).size <= capBytes)
|
|
286
|
+
files.push({ path: file, name: `trace-${basename(file)}` });
|
|
287
|
+
}
|
|
288
|
+
if (!files.length)
|
|
289
|
+
return;
|
|
290
|
+
const form = new FormData();
|
|
291
|
+
for (const f of files) {
|
|
292
|
+
form.append('file', new Blob([readFileSync(f.path)], {
|
|
293
|
+
type: f.name.endsWith('.zip') ? 'application/zip' : 'image/png',
|
|
294
|
+
}), f.name);
|
|
295
|
+
}
|
|
296
|
+
await this.request('POST', `/rest/api/3/issue/${key}/attachments`, form, {
|
|
297
|
+
'X-Atlassian-Token': 'no-check',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async comment(key, content) {
|
|
301
|
+
await this.request('POST', `/rest/api/3/issue/${key}/comment`, {
|
|
302
|
+
body: { type: 'doc', version: 1, content },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async linkIssue(_fingerprint, key) {
|
|
306
|
+
const data = await this.request('GET', `/rest/api/3/issue/${encodeURIComponent(key)}?fields=status,summary`);
|
|
307
|
+
return {
|
|
308
|
+
provider: this.name,
|
|
309
|
+
key: data.key,
|
|
310
|
+
url: this.browseUrl(data.key),
|
|
311
|
+
status: statusFromCategory(data.fields?.status?.statusCategory?.key),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
async syncStatuses(links) {
|
|
315
|
+
const out = [];
|
|
316
|
+
for (let i = 0; i < links.length; i += 50) {
|
|
317
|
+
const batch = links.slice(i, i + 50);
|
|
318
|
+
try {
|
|
319
|
+
const data = await this.request('POST', '/rest/api/3/search/jql', {
|
|
320
|
+
jql: `key in (${batch.map((l) => l.externalKey).join(',')})`,
|
|
321
|
+
maxResults: batch.length,
|
|
322
|
+
fields: ['status'],
|
|
323
|
+
});
|
|
324
|
+
const byKey = new Map((data.issues ?? []).map((i) => [i.key, i]));
|
|
325
|
+
for (const l of batch) {
|
|
326
|
+
const issue = byKey.get(l.externalKey);
|
|
327
|
+
out.push({
|
|
328
|
+
provider: this.name,
|
|
329
|
+
key: l.externalKey,
|
|
330
|
+
url: l.externalUrl,
|
|
331
|
+
status: issue
|
|
332
|
+
? statusFromCategory(issue.fields?.status?.statusCategory?.key)
|
|
333
|
+
: 'unknown',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
for (const l of batch)
|
|
339
|
+
out.push({
|
|
340
|
+
provider: this.name,
|
|
341
|
+
key: l.externalKey,
|
|
342
|
+
url: l.externalUrl,
|
|
343
|
+
status: 'unknown',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
async onScenarioPassed(link, run, ctx) {
|
|
350
|
+
if (ctx.dryRun)
|
|
351
|
+
return {
|
|
352
|
+
provider: this.name,
|
|
353
|
+
kind: this.config.transitionOnPass ? 'transition' : 'issue-commented',
|
|
354
|
+
target: link.externalKey,
|
|
355
|
+
detail: '[dry-run]',
|
|
356
|
+
};
|
|
357
|
+
await this.comment(link.externalKey, [
|
|
358
|
+
paragraph(`Passing again in run ${run.id}${ctx.ci.runUrl ? ` (${ctx.ci.runUrl})` : ''}.`),
|
|
359
|
+
]);
|
|
360
|
+
if (this.config.transitionOnPass) {
|
|
361
|
+
const data = await this.request('GET', `/rest/api/3/issue/${link.externalKey}/transitions`);
|
|
362
|
+
const target = data.transitions?.find((t) => t.name.toLowerCase() === this.config.transitionOnPass.toLowerCase());
|
|
363
|
+
if (target) {
|
|
364
|
+
await this.request('POST', `/rest/api/3/issue/${link.externalKey}/transitions`, {
|
|
365
|
+
transition: { id: target.id },
|
|
366
|
+
});
|
|
367
|
+
await ctx.store.save({
|
|
368
|
+
...link,
|
|
369
|
+
status: 'closed',
|
|
370
|
+
closedAt: new Date().toISOString(),
|
|
371
|
+
lastRunId: run.id,
|
|
372
|
+
});
|
|
373
|
+
return {
|
|
374
|
+
provider: this.name,
|
|
375
|
+
kind: 'transition',
|
|
376
|
+
target: link.externalKey,
|
|
377
|
+
url: link.externalUrl,
|
|
378
|
+
detail: `→ ${target.name}`,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
ctx.logger.warn(`Transition "${this.config.transitionOnPass}" not available on ${link.externalKey}`);
|
|
382
|
+
}
|
|
383
|
+
await ctx.store.save({ ...link, lastRunId: run.id });
|
|
384
|
+
return {
|
|
385
|
+
provider: this.name,
|
|
386
|
+
kind: 'issue-commented',
|
|
387
|
+
target: link.externalKey,
|
|
388
|
+
url: link.externalUrl,
|
|
389
|
+
detail: 'passing again',
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
browseUrl(key) {
|
|
393
|
+
return `${this.base}/browse/${key}`;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function statusFromCategory(key) {
|
|
397
|
+
if (!key)
|
|
398
|
+
return 'unknown';
|
|
399
|
+
return key === 'done' ? 'closed' : 'open';
|
|
400
|
+
}
|
|
401
|
+
function paragraph(text) {
|
|
402
|
+
return { type: 'paragraph', content: [{ type: 'text', text }] };
|
|
403
|
+
}
|
|
404
|
+
function heading(text, level) {
|
|
405
|
+
return { type: 'heading', attrs: { level }, content: [{ type: 'text', text }] };
|
|
406
|
+
}
|
|
407
|
+
function codeBlock(text, language) {
|
|
408
|
+
return {
|
|
409
|
+
type: 'codeBlock',
|
|
410
|
+
attrs: language ? { language } : {},
|
|
411
|
+
content: [{ type: 'text', text: text || ' ' }],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function bullets(items) {
|
|
415
|
+
return {
|
|
416
|
+
type: 'bulletList',
|
|
417
|
+
content: items.map((t) => ({ type: 'listItem', content: [paragraph(t)] })),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
//# sourceMappingURL=jira.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ProjectConfig } from '@sdods/contracts';
|
|
2
|
+
import type { IntegrationProvider, ProviderName } from './types.js';
|
|
3
|
+
export interface ProviderEntry {
|
|
4
|
+
name: ProviderName;
|
|
5
|
+
provider: IntegrationProvider;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
/** env var name → present? (values are never exposed) */
|
|
8
|
+
secrets: Record<string, boolean>;
|
|
9
|
+
initError?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface GetProvidersOptions {
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
only?: ProviderName[];
|
|
14
|
+
projectRoot?: string;
|
|
15
|
+
/** initialise providers (needs secrets); false = list only */
|
|
16
|
+
init?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Build providers from a project's `integrations` section. Missing secrets become `initError`, not throws. */
|
|
19
|
+
export declare function getProviders(project: Pick<ProjectConfig, 'integrations'>, opts?: GetProvidersOptions): Promise<ProviderEntry[]>;
|
|
20
|
+
//# sourceMappingURL=registry.d.ts.map
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { GitHubProvider } from './github.js';
|
|
4
|
+
import { JiraProvider } from './jira.js';
|
|
5
|
+
import { readSecrets, secretPresence } from './secrets.js';
|
|
6
|
+
/** Build providers from a project's `integrations` section. Missing secrets become `initError`, not throws. */
|
|
7
|
+
export async function getProviders(project, opts = {}) {
|
|
8
|
+
const env = opts.env ?? process.env;
|
|
9
|
+
const entries = [];
|
|
10
|
+
const want = (name) => !opts.only || opts.only.includes(name);
|
|
11
|
+
const gh = project.integrations.github;
|
|
12
|
+
if (gh && want('github')) {
|
|
13
|
+
const provider = new GitHubProvider();
|
|
14
|
+
const entry = {
|
|
15
|
+
name: 'github',
|
|
16
|
+
provider,
|
|
17
|
+
enabled: gh.enabled,
|
|
18
|
+
secrets: secretPresence({ tokenEnv: gh.tokenEnv }, env),
|
|
19
|
+
};
|
|
20
|
+
if (gh.enabled && opts.init !== false) {
|
|
21
|
+
try {
|
|
22
|
+
await provider.init(gh, readSecrets({ tokenEnv: gh.tokenEnv }, env));
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
entry.initError = e.message;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
entries.push(entry);
|
|
29
|
+
}
|
|
30
|
+
const jira = project.integrations.jira;
|
|
31
|
+
if (jira && want('jira')) {
|
|
32
|
+
const provider = new JiraProvider();
|
|
33
|
+
const entry = {
|
|
34
|
+
name: 'jira',
|
|
35
|
+
provider,
|
|
36
|
+
enabled: jira.enabled,
|
|
37
|
+
secrets: secretPresence({
|
|
38
|
+
tokenEnv: jira.tokenEnv,
|
|
39
|
+
emailEnv: jira.authMode === 'basic' ? jira.emailEnv : undefined,
|
|
40
|
+
}, env),
|
|
41
|
+
};
|
|
42
|
+
if (jira.enabled && opts.init !== false) {
|
|
43
|
+
try {
|
|
44
|
+
await provider.init(jira, readSecrets({ tokenEnv: jira.tokenEnv, emailEnv: jira.emailEnv }, env));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
entry.initError = e.message;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
entries.push(entry);
|
|
51
|
+
}
|
|
52
|
+
for (const custom of project.integrations.custom) {
|
|
53
|
+
const specifier = custom.module.startsWith('.')
|
|
54
|
+
? pathToFileURL(resolve(opts.projectRoot ?? process.cwd(), custom.module)).href
|
|
55
|
+
: custom.module;
|
|
56
|
+
try {
|
|
57
|
+
const mod = (await import(specifier));
|
|
58
|
+
const provider = mod.createProvider
|
|
59
|
+
? mod.createProvider(custom.options)
|
|
60
|
+
: mod.default
|
|
61
|
+
? new mod.default(custom.options)
|
|
62
|
+
: null;
|
|
63
|
+
if (!provider)
|
|
64
|
+
throw new Error(`module ${custom.module} exports neither createProvider nor a default class`);
|
|
65
|
+
if (!want(provider.name))
|
|
66
|
+
continue;
|
|
67
|
+
const entry = { name: provider.name, provider, enabled: true, secrets: {} };
|
|
68
|
+
if (opts.init !== false) {
|
|
69
|
+
try {
|
|
70
|
+
await provider.init(custom.options ?? {}, {});
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
entry.initError = e.message;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
entries.push(entry);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
entries.push({
|
|
80
|
+
name: custom.module,
|
|
81
|
+
provider: null,
|
|
82
|
+
enabled: true,
|
|
83
|
+
secrets: {},
|
|
84
|
+
initError: e.message,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=registry.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CiInfo, IntegrationSecrets } from './types.js';
|
|
2
|
+
/** Read secrets by env var NAME from the given environment; values are never logged. */
|
|
3
|
+
export declare function readSecrets(names: {
|
|
4
|
+
tokenEnv?: string;
|
|
5
|
+
emailEnv?: string;
|
|
6
|
+
}, env?: NodeJS.ProcessEnv): IntegrationSecrets;
|
|
7
|
+
export declare function secretPresence(names: {
|
|
8
|
+
tokenEnv?: string;
|
|
9
|
+
emailEnv?: string;
|
|
10
|
+
}, env?: NodeJS.ProcessEnv): Record<string, boolean>;
|
|
11
|
+
/** Detect CI context (GitHub Actions and GitLab CI; everything else is "other"). */
|
|
12
|
+
export declare function detectCi(env?: NodeJS.ProcessEnv): CiInfo;
|
|
13
|
+
//# sourceMappingURL=secrets.d.ts.map
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Read secrets by env var NAME from the given environment; values are never logged. */
|
|
2
|
+
export function readSecrets(names, env = process.env) {
|
|
3
|
+
return {
|
|
4
|
+
token: names.tokenEnv ? env[names.tokenEnv] : undefined,
|
|
5
|
+
email: names.emailEnv ? env[names.emailEnv] : undefined,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function secretPresence(names, env = process.env) {
|
|
9
|
+
const out = {};
|
|
10
|
+
if (names.tokenEnv)
|
|
11
|
+
out[names.tokenEnv] = Boolean(env[names.tokenEnv]);
|
|
12
|
+
if (names.emailEnv)
|
|
13
|
+
out[names.emailEnv] = Boolean(env[names.emailEnv]);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
/** Detect CI context (GitHub Actions and GitLab CI; everything else is "other"). */
|
|
17
|
+
export function detectCi(env = process.env) {
|
|
18
|
+
if (env.GITHUB_ACTIONS === 'true') {
|
|
19
|
+
const ref = env.GITHUB_REF ?? '';
|
|
20
|
+
const prMatch = /^refs\/pull\/(\d+)\//.exec(ref);
|
|
21
|
+
const server = env.GITHUB_SERVER_URL ?? 'https://github.com';
|
|
22
|
+
const repo = env.GITHUB_REPOSITORY;
|
|
23
|
+
const runId = env.GITHUB_RUN_ID;
|
|
24
|
+
return {
|
|
25
|
+
provider: 'github',
|
|
26
|
+
isPullRequest: env.GITHUB_EVENT_NAME === 'pull_request' || Boolean(prMatch),
|
|
27
|
+
prNumber: prMatch ? Number(prMatch[1]) : undefined,
|
|
28
|
+
sha: env.GITHUB_SHA,
|
|
29
|
+
branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
|
|
30
|
+
runUrl: repo && runId ? `${server}/${repo}/actions/runs/${runId}` : undefined,
|
|
31
|
+
artifactUrl: repo && runId ? `${server}/${repo}/actions/runs/${runId}#artifacts` : undefined,
|
|
32
|
+
eventName: env.GITHUB_EVENT_NAME,
|
|
33
|
+
repository: repo,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (env.GITLAB_CI === 'true') {
|
|
37
|
+
return {
|
|
38
|
+
provider: 'gitlab',
|
|
39
|
+
isPullRequest: Boolean(env.CI_MERGE_REQUEST_IID),
|
|
40
|
+
prNumber: env.CI_MERGE_REQUEST_IID ? Number(env.CI_MERGE_REQUEST_IID) : undefined,
|
|
41
|
+
sha: env.CI_COMMIT_SHA,
|
|
42
|
+
branch: env.CI_COMMIT_REF_NAME,
|
|
43
|
+
runUrl: env.CI_JOB_URL,
|
|
44
|
+
artifactUrl: env.CI_JOB_URL ? `${env.CI_JOB_URL}/artifacts/browse` : undefined,
|
|
45
|
+
repository: env.CI_PROJECT_PATH,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { provider: env.CI ? 'other' : undefined, isPullRequest: false };
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=secrets.js.map
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { IssueLink, IssueLinkStore, ProviderName } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* JSON-file store so the CLI works without a database (`.sdods/issue-links.json`).
|
|
4
|
+
* Pass `null` as the file for an in-memory store (tests). Not safe for concurrent writers across machines.
|
|
5
|
+
*/
|
|
6
|
+
export declare class FileIssueLinkStore implements IssueLinkStore {
|
|
7
|
+
private readonly file;
|
|
8
|
+
private links;
|
|
9
|
+
constructor(file: string | null);
|
|
10
|
+
private load;
|
|
11
|
+
private persist;
|
|
12
|
+
findOpen(projectSlug: string, provider: ProviderName, fingerprint: string): Promise<IssueLink | undefined>;
|
|
13
|
+
findByKey(projectSlug: string, provider: ProviderName, externalKey: string): Promise<IssueLink | undefined>;
|
|
14
|
+
save(input: Parameters<IssueLinkStore['save']>[0]): Promise<IssueLink>;
|
|
15
|
+
list(projectSlug: string, provider?: ProviderName): Promise<IssueLink[]>;
|
|
16
|
+
}
|
|
17
|
+
export declare function createMemoryStore(): IssueLinkStore;
|
|
18
|
+
//# sourceMappingURL=store.d.ts.map
|