@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/github.js
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { Octokit } from '@octokit/rest';
|
|
4
|
+
import { gherkinBlock, selectIssueScreenshots, truncate } from './context.js';
|
|
5
|
+
import { IssueDedupe, fingerprintMarker, issueTitle } from './dedupe.js';
|
|
6
|
+
export const PR_COMMENT_MARKER = '<!-- sdods:run-summary -->';
|
|
7
|
+
const ANNOTATION_BATCH = 50;
|
|
8
|
+
export class GitHubProvider {
|
|
9
|
+
options;
|
|
10
|
+
name = 'github';
|
|
11
|
+
config;
|
|
12
|
+
octokit;
|
|
13
|
+
owner = '';
|
|
14
|
+
repo = '';
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
async init(config, secrets) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
const [envOwner, envRepo] = (process.env.GITHUB_REPOSITORY ?? '').split('/');
|
|
21
|
+
this.owner = config.owner ?? envOwner ?? '';
|
|
22
|
+
this.repo = config.repo ?? envRepo ?? '';
|
|
23
|
+
if (!this.owner || !this.repo)
|
|
24
|
+
throw new Error('GitHub integration needs owner and repo (or GITHUB_REPOSITORY).');
|
|
25
|
+
if (!secrets.token && !this.options.octokit) {
|
|
26
|
+
throw new Error(`GitHub integration needs a token in $${config.tokenEnv}.`);
|
|
27
|
+
}
|
|
28
|
+
this.octokit =
|
|
29
|
+
this.options.octokit ??
|
|
30
|
+
new Octokit({
|
|
31
|
+
auth: secrets.token,
|
|
32
|
+
baseUrl: this.options.baseUrl ?? process.env.GITHUB_API_URL,
|
|
33
|
+
userAgent: 'sdods',
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async test() {
|
|
37
|
+
try {
|
|
38
|
+
const { data } = await this.octokit.rest.repos.get({ owner: this.owner, repo: this.repo });
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
detail: `repo ${data.full_name} reachable (${data.private ? 'private' : 'public'})`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return { ok: false, detail: e.message };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async onRunFinished(summary, ctx) {
|
|
49
|
+
const actions = [];
|
|
50
|
+
const { run } = summary;
|
|
51
|
+
if (this.config.checkRun && ctx.ci.provider === 'github' && ctx.ci.sha) {
|
|
52
|
+
actions.push(...(await this.publishCheckRuns(summary, ctx)));
|
|
53
|
+
}
|
|
54
|
+
if (this.config.prComment && ctx.ci.isPullRequest && ctx.ci.prNumber) {
|
|
55
|
+
actions.push(await this.upsertPrComment(summary, ctx));
|
|
56
|
+
}
|
|
57
|
+
const candidates = this.failuresToReport(summary);
|
|
58
|
+
const dedupe = new IssueDedupe(ctx.store);
|
|
59
|
+
for (const scenario of candidates) {
|
|
60
|
+
const decision = await dedupe.decide(run.projectSlug, this.name, scenario.fingerprint);
|
|
61
|
+
if (decision.action === 'create') {
|
|
62
|
+
if (ctx.dryRun) {
|
|
63
|
+
actions.push({
|
|
64
|
+
provider: this.name,
|
|
65
|
+
kind: 'issue-created',
|
|
66
|
+
fingerprint: scenario.fingerprint,
|
|
67
|
+
detail: `[dry-run] would create "${issueTitle(scenario.featureName, scenario.scenarioName, scenario.browser)}"`,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const ref = await this.createIssue({
|
|
72
|
+
projectSlug: run.projectSlug,
|
|
73
|
+
run,
|
|
74
|
+
scenario,
|
|
75
|
+
screenshots: selectIssueScreenshots(scenario),
|
|
76
|
+
reportUrl: summary.reportUrl,
|
|
77
|
+
}, ctx);
|
|
78
|
+
actions.push({
|
|
79
|
+
provider: this.name,
|
|
80
|
+
kind: 'issue-created',
|
|
81
|
+
target: ref.key,
|
|
82
|
+
url: ref.url,
|
|
83
|
+
fingerprint: scenario.fingerprint,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else if (decision.action === 'comment' && decision.link) {
|
|
87
|
+
const link = decision.link;
|
|
88
|
+
if (ctx.dryRun) {
|
|
89
|
+
actions.push({
|
|
90
|
+
provider: this.name,
|
|
91
|
+
kind: 'issue-commented',
|
|
92
|
+
target: link.externalKey,
|
|
93
|
+
fingerprint: scenario.fingerprint,
|
|
94
|
+
detail: '[dry-run] would comment "failed again"',
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
await this.octokit.rest.issues.createComment({
|
|
99
|
+
owner: this.owner,
|
|
100
|
+
repo: this.repo,
|
|
101
|
+
issue_number: issueNumber(link.externalKey),
|
|
102
|
+
body: `Failed again in run \`${run.id}\`${this.runLine(summary, ctx)}\n\n\`\`\`\n${truncate(scenario.errorMessage, 1500)}\n\`\`\``,
|
|
103
|
+
});
|
|
104
|
+
await ctx.store.save({ ...link, lastRunId: run.id });
|
|
105
|
+
actions.push({
|
|
106
|
+
provider: this.name,
|
|
107
|
+
kind: 'issue-commented',
|
|
108
|
+
target: link.externalKey,
|
|
109
|
+
url: link.externalUrl,
|
|
110
|
+
fingerprint: scenario.fingerprint,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
actions.push({
|
|
115
|
+
provider: this.name,
|
|
116
|
+
kind: 'skipped',
|
|
117
|
+
fingerprint: scenario.fingerprint,
|
|
118
|
+
detail: decision.reason,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// scenarios that now pass but have an open issue
|
|
123
|
+
for (const scenario of summary.passed) {
|
|
124
|
+
const link = await ctx.store.findOpen(run.projectSlug, this.name, scenario.fingerprint);
|
|
125
|
+
if (!link || link.source === 'tag')
|
|
126
|
+
continue;
|
|
127
|
+
const action = await this.onScenarioPassed(link, run, ctx);
|
|
128
|
+
if (action)
|
|
129
|
+
actions.push(action);
|
|
130
|
+
}
|
|
131
|
+
return { provider: this.name, actions };
|
|
132
|
+
}
|
|
133
|
+
failuresToReport(summary) {
|
|
134
|
+
const mode = this.config.createIssueOnFailure;
|
|
135
|
+
if (mode === 'never')
|
|
136
|
+
return [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
return summary.failed.filter((s) => {
|
|
139
|
+
if (seen.has(s.fingerprint))
|
|
140
|
+
return false;
|
|
141
|
+
seen.add(s.fingerprint);
|
|
142
|
+
return mode === 'always' || s.suiteTag === '@smoke';
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
runLine(summary, ctx) {
|
|
146
|
+
const links = [];
|
|
147
|
+
if (summary.reportUrl)
|
|
148
|
+
links.push(`[report](${summary.reportUrl})`);
|
|
149
|
+
if (ctx.ci.runUrl)
|
|
150
|
+
links.push(`[CI run](${ctx.ci.runUrl})`);
|
|
151
|
+
return links.length ? ` · ${links.join(' · ')}` : '';
|
|
152
|
+
}
|
|
153
|
+
async publishCheckRuns(summary, ctx) {
|
|
154
|
+
const actions = [];
|
|
155
|
+
const groups = new Map();
|
|
156
|
+
for (const s of summary.scenarios) {
|
|
157
|
+
const key = s.browser ?? s.layer;
|
|
158
|
+
groups.set(key, [...(groups.get(key) ?? []), s]);
|
|
159
|
+
}
|
|
160
|
+
for (const [group, scenarios] of groups) {
|
|
161
|
+
const failed = scenarios.filter((s) => s.status === 'failed' || s.status === 'timedOut');
|
|
162
|
+
const flaky = scenarios.filter((s) => s.flaky);
|
|
163
|
+
const name = `SDODS / ${summary.run.projectSlug} / ${group}`;
|
|
164
|
+
const conclusion = failed.length ? 'failure' : 'success';
|
|
165
|
+
const title = `${scenarios.length - failed.length}/${scenarios.length} passed${flaky.length ? `, ${flaky.length} flaky` : ''}`;
|
|
166
|
+
const text = this.checkSummaryMarkdown(summary, scenarios, failed, flaky, ctx);
|
|
167
|
+
const annotations = failed.map((s) => ({
|
|
168
|
+
path: s.featureUri,
|
|
169
|
+
start_line: s.line ?? 1,
|
|
170
|
+
end_line: s.line ?? 1,
|
|
171
|
+
annotation_level: 'failure',
|
|
172
|
+
title: s.scenarioName,
|
|
173
|
+
message: truncate(s.errorMessage ?? 'failed', 1000) || 'failed',
|
|
174
|
+
}));
|
|
175
|
+
if (ctx.dryRun) {
|
|
176
|
+
actions.push({
|
|
177
|
+
provider: this.name,
|
|
178
|
+
kind: 'check-run',
|
|
179
|
+
target: name,
|
|
180
|
+
detail: `[dry-run] ${conclusion}: ${title}, ${annotations.length} annotation(s)`,
|
|
181
|
+
});
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const first = annotations.slice(0, ANNOTATION_BATCH);
|
|
185
|
+
const { data } = await this.octokit.rest.checks.create({
|
|
186
|
+
owner: this.owner,
|
|
187
|
+
repo: this.repo,
|
|
188
|
+
name,
|
|
189
|
+
head_sha: ctx.ci.sha,
|
|
190
|
+
status: 'completed',
|
|
191
|
+
conclusion,
|
|
192
|
+
details_url: summary.reportUrl ?? ctx.ci.runUrl,
|
|
193
|
+
output: { title, summary: text, annotations: first },
|
|
194
|
+
});
|
|
195
|
+
for (let i = ANNOTATION_BATCH; i < annotations.length; i += ANNOTATION_BATCH) {
|
|
196
|
+
await this.octokit.rest.checks.update({
|
|
197
|
+
owner: this.owner,
|
|
198
|
+
repo: this.repo,
|
|
199
|
+
check_run_id: data.id,
|
|
200
|
+
output: { title, summary: text, annotations: annotations.slice(i, i + ANNOTATION_BATCH) },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
actions.push({
|
|
204
|
+
provider: this.name,
|
|
205
|
+
kind: 'check-run',
|
|
206
|
+
target: name,
|
|
207
|
+
url: data.html_url ?? undefined,
|
|
208
|
+
detail: `${conclusion}: ${title}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return actions;
|
|
212
|
+
}
|
|
213
|
+
checkSummaryMarkdown(summary, scenarios, failed, flaky, ctx) {
|
|
214
|
+
const t = summary.totals;
|
|
215
|
+
const lines = [
|
|
216
|
+
`| total | passed | failed | skipped | flaky | duration |`,
|
|
217
|
+
`|---|---|---|---|---|---|`,
|
|
218
|
+
`| ${scenarios.length} | ${scenarios.length - failed.length} | ${failed.length} | ${scenarios.filter((s) => s.status === 'skipped').length} | ${flaky.length} | ${formatMs(t.durationMs)} |`,
|
|
219
|
+
'',
|
|
220
|
+
];
|
|
221
|
+
if (failed.length) {
|
|
222
|
+
lines.push('**Failed**');
|
|
223
|
+
for (const s of failed)
|
|
224
|
+
lines.push(`- \`${s.featureUri}\` › ${s.scenarioName}${s.errorMessage ? ` — ${firstLine(s.errorMessage)}` : ''}`);
|
|
225
|
+
lines.push('');
|
|
226
|
+
}
|
|
227
|
+
if (flaky.length) {
|
|
228
|
+
lines.push('**Flaky (passed on retry)**');
|
|
229
|
+
for (const s of flaky)
|
|
230
|
+
lines.push(`- ${s.scenarioName}`);
|
|
231
|
+
lines.push('');
|
|
232
|
+
}
|
|
233
|
+
const line = this.runLine(summary, ctx);
|
|
234
|
+
if (line)
|
|
235
|
+
lines.push(`Run \`${summary.run.id}\`${line}`);
|
|
236
|
+
return lines.join('\n');
|
|
237
|
+
}
|
|
238
|
+
async upsertPrComment(summary, ctx) {
|
|
239
|
+
const prNumber = ctx.ci.prNumber;
|
|
240
|
+
const t = summary.totals;
|
|
241
|
+
const status = t.failed ? '❌' : '✅';
|
|
242
|
+
const body = [
|
|
243
|
+
PR_COMMENT_MARKER,
|
|
244
|
+
`### ${status} SDODS · ${summary.run.projectSlug} · ${summary.run.env}`,
|
|
245
|
+
'',
|
|
246
|
+
`| total | passed | failed | skipped | flaky | duration |`,
|
|
247
|
+
`|---|---|---|---|---|---|`,
|
|
248
|
+
`| ${t.total} | ${t.passed} | ${t.failed} | ${t.skipped} | ${t.flaky} | ${formatMs(t.durationMs)} |`,
|
|
249
|
+
'',
|
|
250
|
+
...(summary.failed.length
|
|
251
|
+
? [
|
|
252
|
+
'<details><summary>Failed scenarios</summary>',
|
|
253
|
+
'',
|
|
254
|
+
...summary.failed.map((s) => `- \`${s.runnerProject}\` ${s.featureName} › ${s.scenarioName}${s.errorMessage ? `<br/><code>${escapeHtml(firstLine(s.errorMessage))}</code>` : ''}`),
|
|
255
|
+
'',
|
|
256
|
+
'</details>',
|
|
257
|
+
'',
|
|
258
|
+
]
|
|
259
|
+
: []),
|
|
260
|
+
`Run \`${summary.run.id}\`${this.runLine(summary, ctx)}`,
|
|
261
|
+
].join('\n');
|
|
262
|
+
if (ctx.dryRun)
|
|
263
|
+
return {
|
|
264
|
+
provider: this.name,
|
|
265
|
+
kind: 'pr-comment',
|
|
266
|
+
target: `#${prNumber}`,
|
|
267
|
+
detail: '[dry-run] would upsert PR comment',
|
|
268
|
+
};
|
|
269
|
+
const existing = await this.octokit.paginate(this.octokit.rest.issues.listComments, {
|
|
270
|
+
owner: this.owner,
|
|
271
|
+
repo: this.repo,
|
|
272
|
+
issue_number: prNumber,
|
|
273
|
+
per_page: 100,
|
|
274
|
+
});
|
|
275
|
+
const mine = existing.find((c) => c.body?.includes(PR_COMMENT_MARKER));
|
|
276
|
+
if (mine) {
|
|
277
|
+
const { data } = await this.octokit.rest.issues.updateComment({
|
|
278
|
+
owner: this.owner,
|
|
279
|
+
repo: this.repo,
|
|
280
|
+
comment_id: mine.id,
|
|
281
|
+
body,
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
provider: this.name,
|
|
285
|
+
kind: 'pr-comment',
|
|
286
|
+
target: `#${prNumber}`,
|
|
287
|
+
url: data.html_url,
|
|
288
|
+
detail: 'updated',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const { data } = await this.octokit.rest.issues.createComment({
|
|
292
|
+
owner: this.owner,
|
|
293
|
+
repo: this.repo,
|
|
294
|
+
issue_number: prNumber,
|
|
295
|
+
body,
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
provider: this.name,
|
|
299
|
+
kind: 'pr-comment',
|
|
300
|
+
target: `#${prNumber}`,
|
|
301
|
+
url: data.html_url,
|
|
302
|
+
detail: 'created',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async createIssue(input, ctx) {
|
|
306
|
+
const { scenario, run } = input;
|
|
307
|
+
const title = issueTitle(scenario.featureName, scenario.scenarioName, scenario.browser);
|
|
308
|
+
const shotLines = [];
|
|
309
|
+
for (const shot of input.screenshots) {
|
|
310
|
+
let url = ctx.artifactUrl(shot, run);
|
|
311
|
+
if (this.config.uploadToRelease) {
|
|
312
|
+
const uploaded = await this.uploadReleaseAsset(shot, run, ctx).catch(() => null);
|
|
313
|
+
if (uploaded)
|
|
314
|
+
url = uploaded;
|
|
315
|
+
}
|
|
316
|
+
if (url)
|
|
317
|
+
shotLines.push(`- ${shot.phase ?? 'screenshot'}${shot.stepIndex != null ? ` (step ${shot.stepIndex})` : ''}: ${url.endsWith('.png') ? `` : url}`);
|
|
318
|
+
}
|
|
319
|
+
const body = [
|
|
320
|
+
`**Project** \`${run.projectSlug}\` · **Environment** \`${run.env}\` · **Browser** \`${scenario.browser ?? scenario.layer}\` · **Run** \`${run.id}\``,
|
|
321
|
+
run.gitSha
|
|
322
|
+
? `**Commit** \`${run.gitSha.slice(0, 10)}\`${run.gitBranch ? ` on \`${run.gitBranch}\`` : ''}`
|
|
323
|
+
: '',
|
|
324
|
+
'',
|
|
325
|
+
'### Scenario',
|
|
326
|
+
'```gherkin',
|
|
327
|
+
gherkinBlock(scenario),
|
|
328
|
+
'```',
|
|
329
|
+
'',
|
|
330
|
+
'### Error',
|
|
331
|
+
'```',
|
|
332
|
+
truncate(scenario.errorMessage ?? 'no error message', 3000),
|
|
333
|
+
'```',
|
|
334
|
+
'',
|
|
335
|
+
...(shotLines.length ? ['### Screenshots', ...shotLines, ''] : []),
|
|
336
|
+
...(input.reportUrl || ctx.ci.runUrl
|
|
337
|
+
? [
|
|
338
|
+
'### Links',
|
|
339
|
+
...(input.reportUrl ? [`- Report: ${input.reportUrl}`] : []),
|
|
340
|
+
...(ctx.ci.runUrl ? [`- CI run: ${ctx.ci.runUrl}`] : []),
|
|
341
|
+
'',
|
|
342
|
+
]
|
|
343
|
+
: []),
|
|
344
|
+
`<!-- ${fingerprintMarker(scenario.fingerprint)} -->`,
|
|
345
|
+
]
|
|
346
|
+
.filter((l) => l !== undefined)
|
|
347
|
+
.join('\n');
|
|
348
|
+
const { data } = await this.octokit.rest.issues.create({
|
|
349
|
+
owner: this.owner,
|
|
350
|
+
repo: this.repo,
|
|
351
|
+
title,
|
|
352
|
+
body,
|
|
353
|
+
labels: this.config.labels,
|
|
354
|
+
});
|
|
355
|
+
const ref = {
|
|
356
|
+
provider: this.name,
|
|
357
|
+
key: `${this.owner}/${this.repo}#${data.number}`,
|
|
358
|
+
url: data.html_url,
|
|
359
|
+
status: 'open',
|
|
360
|
+
};
|
|
361
|
+
await ctx.store.save({
|
|
362
|
+
projectSlug: input.projectSlug,
|
|
363
|
+
provider: this.name,
|
|
364
|
+
fingerprint: scenario.fingerprint,
|
|
365
|
+
scenarioName: scenario.scenarioName,
|
|
366
|
+
externalKey: ref.key,
|
|
367
|
+
externalUrl: ref.url,
|
|
368
|
+
status: 'open',
|
|
369
|
+
source: 'auto',
|
|
370
|
+
lastRunId: run.id,
|
|
371
|
+
lastSyncedAt: new Date().toISOString(),
|
|
372
|
+
});
|
|
373
|
+
return ref;
|
|
374
|
+
}
|
|
375
|
+
async uploadReleaseAsset(shot, run, ctx) {
|
|
376
|
+
const file = ctx.artifactPath(shot, run);
|
|
377
|
+
if (!file || !this.config.uploadToRelease)
|
|
378
|
+
return null;
|
|
379
|
+
const { data: release } = await this.octokit.rest.repos.getReleaseByTag({
|
|
380
|
+
owner: this.owner,
|
|
381
|
+
repo: this.repo,
|
|
382
|
+
tag: this.config.uploadToRelease,
|
|
383
|
+
});
|
|
384
|
+
const name = `${run.id}-${basename(file)}`;
|
|
385
|
+
const { data } = await this.octokit.rest.repos.uploadReleaseAsset({
|
|
386
|
+
owner: this.owner,
|
|
387
|
+
repo: this.repo,
|
|
388
|
+
release_id: release.id,
|
|
389
|
+
name,
|
|
390
|
+
data: readFileSync(file),
|
|
391
|
+
headers: { 'content-type': 'image/png', 'content-length': readFileSync(file).length },
|
|
392
|
+
});
|
|
393
|
+
return data.browser_download_url;
|
|
394
|
+
}
|
|
395
|
+
async linkIssue(fingerprint, key) {
|
|
396
|
+
const num = issueNumber(key);
|
|
397
|
+
const { data } = await this.octokit.rest.issues.get({
|
|
398
|
+
owner: this.owner,
|
|
399
|
+
repo: this.repo,
|
|
400
|
+
issue_number: num,
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
provider: this.name,
|
|
404
|
+
key: `${this.owner}/${this.repo}#${num}`,
|
|
405
|
+
url: data.html_url,
|
|
406
|
+
status: data.state === 'closed' ? 'closed' : 'open',
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
async syncStatuses(links) {
|
|
410
|
+
const out = [];
|
|
411
|
+
for (const link of links) {
|
|
412
|
+
try {
|
|
413
|
+
out.push(await this.linkIssue(link.fingerprint, link.externalKey));
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
out.push({
|
|
417
|
+
provider: this.name,
|
|
418
|
+
key: link.externalKey,
|
|
419
|
+
url: link.externalUrl,
|
|
420
|
+
status: 'unknown',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
async onScenarioPassed(link, run, ctx) {
|
|
427
|
+
const num = issueNumber(link.externalKey);
|
|
428
|
+
const body = `Passing again in run \`${run.id}\`${ctx.ci.runUrl ? ` · [CI run](${ctx.ci.runUrl})` : ''}.`;
|
|
429
|
+
if (ctx.dryRun)
|
|
430
|
+
return {
|
|
431
|
+
provider: this.name,
|
|
432
|
+
kind: this.config.closeOnPass ? 'issue-closed' : 'issue-commented',
|
|
433
|
+
target: link.externalKey,
|
|
434
|
+
detail: '[dry-run]',
|
|
435
|
+
};
|
|
436
|
+
await this.octokit.rest.issues.createComment({
|
|
437
|
+
owner: this.owner,
|
|
438
|
+
repo: this.repo,
|
|
439
|
+
issue_number: num,
|
|
440
|
+
body,
|
|
441
|
+
});
|
|
442
|
+
if (this.config.closeOnPass) {
|
|
443
|
+
await this.octokit.rest.issues.update({
|
|
444
|
+
owner: this.owner,
|
|
445
|
+
repo: this.repo,
|
|
446
|
+
issue_number: num,
|
|
447
|
+
state: 'closed',
|
|
448
|
+
state_reason: 'completed',
|
|
449
|
+
});
|
|
450
|
+
await ctx.store.save({
|
|
451
|
+
...link,
|
|
452
|
+
status: 'closed',
|
|
453
|
+
closedAt: new Date().toISOString(),
|
|
454
|
+
lastRunId: run.id,
|
|
455
|
+
});
|
|
456
|
+
return {
|
|
457
|
+
provider: this.name,
|
|
458
|
+
kind: 'issue-closed',
|
|
459
|
+
target: link.externalKey,
|
|
460
|
+
url: link.externalUrl,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
await ctx.store.save({ ...link, lastRunId: run.id });
|
|
464
|
+
return {
|
|
465
|
+
provider: this.name,
|
|
466
|
+
kind: 'issue-commented',
|
|
467
|
+
target: link.externalKey,
|
|
468
|
+
url: link.externalUrl,
|
|
469
|
+
detail: 'passing again',
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
export function issueNumber(key) {
|
|
474
|
+
const m = /#?(\d+)$/.exec(key.trim());
|
|
475
|
+
if (!m)
|
|
476
|
+
throw new Error(`Cannot parse GitHub issue number from "${key}"`);
|
|
477
|
+
return Number(m[1]);
|
|
478
|
+
}
|
|
479
|
+
function firstLine(text) {
|
|
480
|
+
return text.split('\n')[0]?.trim() ?? '';
|
|
481
|
+
}
|
|
482
|
+
function formatMs(ms) {
|
|
483
|
+
if (!ms)
|
|
484
|
+
return '0s';
|
|
485
|
+
if (ms < 1000)
|
|
486
|
+
return `${ms}ms`;
|
|
487
|
+
const s = Math.round(ms / 1000);
|
|
488
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
489
|
+
}
|
|
490
|
+
function escapeHtml(text) {
|
|
491
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
492
|
+
}
|
|
493
|
+
//# sourceMappingURL=github.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './store.js';
|
|
3
|
+
export * from './dedupe.js';
|
|
4
|
+
export * from './secrets.js';
|
|
5
|
+
export * from './context.js';
|
|
6
|
+
export * from './github.js';
|
|
7
|
+
export * from './jira.js';
|
|
8
|
+
export * from './registry.js';
|
|
9
|
+
export * from './summary.js';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export * from './store.js';
|
|
3
|
+
export * from './dedupe.js';
|
|
4
|
+
export * from './secrets.js';
|
|
5
|
+
export * from './context.js';
|
|
6
|
+
export * from './github.js';
|
|
7
|
+
export * from './jira.js';
|
|
8
|
+
export * from './registry.js';
|
|
9
|
+
export * from './summary.js';
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
package/dist/jira.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RunRecord } from '@sdods/contracts';
|
|
2
|
+
import type { CreateIssueInput, IntegrationContext, IntegrationProvider, IntegrationSecrets, IssueLink, IssueRef, JiraConfig, NotifyAction, NotifyResult, ProviderTestResult, RunSummaryInput } from './types.js';
|
|
3
|
+
export interface JiraProviderOptions {
|
|
4
|
+
fetch?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
export declare class JiraProvider implements IntegrationProvider<JiraConfig> {
|
|
7
|
+
readonly name: "jira";
|
|
8
|
+
private config;
|
|
9
|
+
private authHeader;
|
|
10
|
+
private base;
|
|
11
|
+
private readonly fetchImpl;
|
|
12
|
+
constructor(options?: JiraProviderOptions);
|
|
13
|
+
init(config: JiraConfig, secrets: IntegrationSecrets): Promise<void>;
|
|
14
|
+
private request;
|
|
15
|
+
test(): Promise<ProviderTestResult>;
|
|
16
|
+
onRunFinished(summary: RunSummaryInput, ctx: IntegrationContext): Promise<NotifyResult>;
|
|
17
|
+
private failuresToReport;
|
|
18
|
+
findByFingerprint(fingerprint: string): Promise<IssueRef | null>;
|
|
19
|
+
createIssue(input: CreateIssueInput, ctx: IntegrationContext): Promise<IssueRef>;
|
|
20
|
+
private attachScreenshots;
|
|
21
|
+
private comment;
|
|
22
|
+
linkIssue(_fingerprint: string, key: string): Promise<IssueRef>;
|
|
23
|
+
syncStatuses(links: IssueLink[]): Promise<IssueRef[]>;
|
|
24
|
+
onScenarioPassed(link: IssueLink, run: RunRecord, ctx: IntegrationContext): Promise<NotifyAction | null>;
|
|
25
|
+
private browseUrl;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=jira.d.ts.map
|