tewip-ai 0.8.1 → 0.10.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/README.md +18 -0
- package/TEAM-GUIDE.md +3 -0
- package/dist/cli/tewip.js +16 -0
- package/dist/engine/flaky/flaky-command.js +142 -0
- package/dist/engine/flaky/flaky.js +104 -0
- package/dist/engine/flaky/quarantine.js +77 -0
- package/dist/engine/work/bug-command.js +20 -25
- package/dist/engine/work/github-issues.js +63 -0
- package/dist/engine/work/rally.js +84 -0
- package/dist/engine/work/tracker.js +37 -0
- package/dist/engine/work/trackers.js +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -166,6 +166,24 @@ that lost its markup) and **5 of 5 harmless changes ignored** (capitals, extra s
|
|
|
166
166
|
implemented as a link, reordered sections). No designer has judged the output yet, so the roadmap's
|
|
167
167
|
own metric is still open.
|
|
168
168
|
|
|
169
|
+
## Which flaky tests to quarantine, and what they cost (new)
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
tewip flaky --days 30 # the dossier: quarantine, fix, or watch - with the evidence
|
|
173
|
+
tewip flaky --apply # writes the quarantine line into the test file
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Other tools count retries. Tewip already decided *why* each failure happened, so it can say: this
|
|
177
|
+
test failed in 161 of 647 runs **across 109 different branches** - that is the test, not your
|
|
178
|
+
change - and quarantining it gives the team back 148 CI minutes a week. It refuses to hide a test
|
|
179
|
+
that mostly fails (that one is broken, not flaky) and **never** quarantines a test whose failures
|
|
180
|
+
looked like a real bug.
|
|
181
|
+
|
|
182
|
+
Measured on three real projects' CI history (Gutenberg twice, Supabase): 76 tests proposed for
|
|
183
|
+
quarantine, **zero of them containing a single real-bug failure**, giving back roughly 34, 148 and
|
|
184
|
+
1,178 CI minutes a week. It also tells you when a quarantined test has stopped failing and should
|
|
185
|
+
come back - the half nobody ever does.
|
|
186
|
+
|
|
169
187
|
## Bugs filed back, and test cases where your team keeps them (Phase 10, completed)
|
|
170
188
|
|
|
171
189
|
```bash
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -127,6 +127,7 @@ collects environment failures in one issue, and (optionally) opens a fix pull re
|
|
|
127
127
|
| `tewip spec "<feature>"` | An epic, its user stories and acceptance criteria, grounded in your code; files them in Jira with `--project PROJ --create` | Before the work is broken down |
|
|
128
128
|
| `tewip report report.xml --gate` | The CI command for GitLab, Jenkins, CircleCI, Azure, Bitbucket and the rest: diagnoses the run, comments on the GitLab merge request, and fails the build only on failures that need a person | Your CI is not GitHub Actions |
|
|
129
129
|
| `tewip watch --cmd "npm test"` | Runs your tests when you save a file and explains each failure straight away | While you are writing code |
|
|
130
|
+
| `tewip flaky --days 30` | Which tests to quarantine, which to fix, what they cost in CI minutes - and when a quarantined one can come back | Monday morning |
|
|
130
131
|
| `tewip note --days 7` | The weekly quality note for a lead, from runs already diagnosed (no model calls, free) | Monday morning |
|
|
131
132
|
| `tewip fix --code report.xml` | Opt-in: proposes the smallest change to application code for a real bug, and keeps it only if the failing test passes and the whole suite still passes | "It is a real bug and I want a starting patch" |
|
|
132
133
|
| `tewip design <figma link> --url <page>` | Compares a Figma frame against the running page: the words the design specifies and the controls its layer names imply | "Did we build what was designed?" |
|
|
@@ -139,6 +140,8 @@ Notes that save you time:
|
|
|
139
140
|
- `write` never touches your application code. It only adds new test files, and you review and
|
|
140
141
|
commit them yourself.
|
|
141
142
|
- Everything it writes locally goes to the `.tewip` folder in your project.
|
|
143
|
+
- Work items go to **Jira, Rally or GitHub Issues** - set `TEWIP_TRACKER`, or just the credentials
|
|
144
|
+
of the one you use. GitHub Issues uses your existing `gh` login, so it needs no new secret.
|
|
142
145
|
- Jira works with **Cloud, Server and Data Center**. To read tickets from a tracker anyone can read
|
|
143
146
|
(many open-source ones), `TEWIP_JIRA_URL` alone is enough - no account. Writing needs a token.
|
|
144
147
|
- `tewip jira` shows what it *would* file and creates nothing until you add `--create`. It never
|
package/dist/cli/tewip.js
CHANGED
|
@@ -71,6 +71,11 @@ Usage:
|
|
|
71
71
|
Beside your editor: runs your tests when a file changes (or
|
|
72
72
|
watches for new reports) and explains each failure as it
|
|
73
73
|
happens, without CI.
|
|
74
|
+
tewip flaky [--days 30] [--apply] [--name <project>] [--out flaky.md]
|
|
75
|
+
Which tests are wasting the team's time, how much CI time they
|
|
76
|
+
cost, and what to do with each: quarantine, fix, or watch. Read
|
|
77
|
+
from runs already diagnosed, so it costs nothing. --apply puts
|
|
78
|
+
the quarantine line in the test file with the evidence above it.
|
|
74
79
|
tewip note [--days 7] [--name <project>] [--out note.md] [--post]
|
|
75
80
|
The weekly quality note for a lead, written from the runs Tewip
|
|
76
81
|
already diagnosed. No model calls, so it costs nothing.
|
|
@@ -254,6 +259,17 @@ async function main() {
|
|
|
254
259
|
});
|
|
255
260
|
return;
|
|
256
261
|
}
|
|
262
|
+
case 'flaky': {
|
|
263
|
+
const { runFlaky } = await import('../engine/flaky/flaky-command.js');
|
|
264
|
+
await runFlaky({
|
|
265
|
+
root: process.cwd(),
|
|
266
|
+
name: flag(args, 'name'),
|
|
267
|
+
days: Math.max(1, Number(flag(args, 'days')) || 30),
|
|
268
|
+
apply: args.includes('--apply'),
|
|
269
|
+
out: flag(args, 'out'),
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
257
273
|
case 'note': {
|
|
258
274
|
const { runNote } = await import('../engine/notify/note-command.js');
|
|
259
275
|
await runNote({
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// `tewip flaky`: the Monday morning answer. Which tests are wasting the team's time, how much, and
|
|
2
|
+
// what to do about each one - read from the runs Tewip already diagnosed, so it costs nothing and
|
|
3
|
+
// cannot re-judge an old failure with today's rules.
|
|
4
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { datasetFiles, readRunSummaries, readTriageRecords } from '../../reporters/store.js';
|
|
7
|
+
import { flakyTests, readyToRelease, weeklyCost } from './flaky.js';
|
|
8
|
+
import { applyQuarantine, frameworkOf, quarantineFor } from './quarantine.js';
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const minutes = (ms) => `${(ms / 60_000).toFixed(1)} CI min`;
|
|
11
|
+
function projects(root, only) {
|
|
12
|
+
const dir = join(root, '.tewip', 'history');
|
|
13
|
+
if (only)
|
|
14
|
+
return [only];
|
|
15
|
+
return existsSync(dir) ? readdirSync(dir).filter((name) => existsSync(join(dir, name, 'triage.jsonl'))) : [];
|
|
16
|
+
}
|
|
17
|
+
/** The test file on disk, when the report's path still exists in this project. */
|
|
18
|
+
function testFile(root, test) {
|
|
19
|
+
const direct = resolve(root, test.file);
|
|
20
|
+
return test.file && existsSync(direct) ? direct : null;
|
|
21
|
+
}
|
|
22
|
+
export async function runFlaky(cmd) {
|
|
23
|
+
const names = projects(cmd.root, cmd.name);
|
|
24
|
+
if (names.length === 0) {
|
|
25
|
+
throw new Error(`no history in ${join(cmd.root, '.tewip', 'history')}. Run \`tewip explain\` or \`tewip report\` on a few runs first: this is read from what those kept.`);
|
|
26
|
+
}
|
|
27
|
+
const since = new Date(Date.now() - cmd.days * DAY_MS).toISOString();
|
|
28
|
+
const all = [];
|
|
29
|
+
const lines = ['# Flaky tests', '', `The last ${cmd.days} days, from runs Tewip already diagnosed. No tests were run and no model was called.`, ''];
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
const history = datasetFiles(join(cmd.root, '.tewip', 'history', name));
|
|
32
|
+
const records = readTriageRecords(history.triage);
|
|
33
|
+
const runs = readRunSummaries(history.runs);
|
|
34
|
+
const tests = flakyTests(records, runs, { since });
|
|
35
|
+
if (tests.length === 0)
|
|
36
|
+
continue;
|
|
37
|
+
all.push(...tests);
|
|
38
|
+
const cost = weeklyCost(tests, cmd.days);
|
|
39
|
+
lines.push(`## ${name}`, '', `${tests.length} test(s) failed at least once. About ${cost.minutes} CI minute(s) and ${cost.attempts} failed attempt(s) a week.`, '');
|
|
40
|
+
const group = (verdict, title, explain) => {
|
|
41
|
+
const chosen = tests.filter((t) => t.verdict === verdict);
|
|
42
|
+
if (chosen.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
lines.push(`### ${title} (${chosen.length})`, '', explain, '');
|
|
45
|
+
for (const test of chosen.slice(0, 15)) {
|
|
46
|
+
lines.push(`- **${test.title}**`);
|
|
47
|
+
lines.push(` - ${test.failedRuns} of ${test.totalRuns} run(s), ${test.failedAttempts} failed attempt(s), ${minutes(test.wastedMs)} lost${test.recoveredOnRetry > 0 ? `, passed on a retry ${test.recoveredOnRetry} time(s)` : ''}`);
|
|
48
|
+
lines.push(` - Tewip read them as: ${test.categories.map((c) => `${c.category} ×${c.count}`).join(', ')}`);
|
|
49
|
+
lines.push(` - Why: ${test.why}`);
|
|
50
|
+
if (test.file)
|
|
51
|
+
lines.push(` - Where: ${test.file}`);
|
|
52
|
+
}
|
|
53
|
+
lines.push('');
|
|
54
|
+
};
|
|
55
|
+
group('quarantine', 'Quarantine these', 'Unreliable rather than wrong. Stop them blocking everyone, then fix them on purpose.');
|
|
56
|
+
group('fix-now', 'Fix these, do not hide them', 'Hiding one of these would hide something real.');
|
|
57
|
+
group('watch', 'Watch', 'Not enough evidence yet to act.');
|
|
58
|
+
// Coming back out is the half nobody ever does.
|
|
59
|
+
const quarantined = readQuarantined(cmd.root);
|
|
60
|
+
const back = readyToRelease(quarantined, records, runs, since);
|
|
61
|
+
if (back.length > 0) {
|
|
62
|
+
lines.push('### Ready to come out of quarantine', '', 'These ran in this period and did not fail once.', '');
|
|
63
|
+
for (const testId of back)
|
|
64
|
+
lines.push(`- ${testId.split(' > ').at(-1)} — take its quarantine line out and let it block again.`);
|
|
65
|
+
lines.push('');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (all.length === 0) {
|
|
69
|
+
console.log(`Nothing failed in the last ${cmd.days} days. Try --days 30.`);
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const toQuarantine = all.filter((t) => t.verdict === 'quarantine');
|
|
73
|
+
if (cmd.apply && toQuarantine.length > 0)
|
|
74
|
+
lines.push(...applyAll(cmd.root, toQuarantine));
|
|
75
|
+
else if (toQuarantine.length > 0)
|
|
76
|
+
lines.push(...suggest(cmd.root, toQuarantine));
|
|
77
|
+
const report = lines.join('\n');
|
|
78
|
+
console.log(report);
|
|
79
|
+
if (cmd.out) {
|
|
80
|
+
writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
|
|
81
|
+
console.log(`\nWritten to ${cmd.out}`);
|
|
82
|
+
}
|
|
83
|
+
return all;
|
|
84
|
+
}
|
|
85
|
+
/** Tests already carrying Tewip's quarantine line, found by reading the test files it knows about. */
|
|
86
|
+
function readQuarantined(root) {
|
|
87
|
+
const file = join(root, '.tewip', 'quarantined.txt');
|
|
88
|
+
return existsSync(file) ? readFileSync(file, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : [];
|
|
89
|
+
}
|
|
90
|
+
function suggest(root, tests) {
|
|
91
|
+
const lines = ['### The line to add', ''];
|
|
92
|
+
for (const test of tests.slice(0, 5)) {
|
|
93
|
+
const path = testFile(root, test);
|
|
94
|
+
const framework = path ? frameworkOf(path, readFileSync(path, 'utf8')) : null;
|
|
95
|
+
if (!path || !framework) {
|
|
96
|
+
lines.push(`- ${test.title}: ${test.file || 'file not found in this project'} - quarantine it the way this project does.`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
lines.push(`- ${test.title} (${test.file}):`, '```', quarantineFor(test, framework).line, '```');
|
|
100
|
+
}
|
|
101
|
+
lines.push('', 'Run `tewip flaky --apply` to put these lines in, each with the evidence above it. Only the named tests are touched.', '');
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
104
|
+
/** Writes the quarantine into the test files, and remembers which tests were quarantined. */
|
|
105
|
+
function applyAll(root, tests) {
|
|
106
|
+
const lines = ['### Applied', ''];
|
|
107
|
+
const done = [];
|
|
108
|
+
for (const test of tests) {
|
|
109
|
+
const path = testFile(root, test);
|
|
110
|
+
if (!path) {
|
|
111
|
+
lines.push(`- ${test.title}: not applied, ${test.file || 'no file'} is not in this project`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const source = readFileSync(path, 'utf8');
|
|
115
|
+
const framework = frameworkOf(path, source);
|
|
116
|
+
if (!framework) {
|
|
117
|
+
lines.push(`- ${test.title}: not applied, the framework of ${test.file} was not recognised`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const changed = applyQuarantine(source, test, framework);
|
|
121
|
+
if (!changed) {
|
|
122
|
+
lines.push(`- ${test.title}: not applied, the test was not found in ${test.file} (was it renamed?)`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (changed === source) {
|
|
126
|
+
lines.push(`- ${test.title}: already quarantined`);
|
|
127
|
+
done.push(test.testId);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
writeFileSync(path, changed, 'utf8');
|
|
131
|
+
done.push(test.testId);
|
|
132
|
+
lines.push(`- ${test.title}: quarantined in ${test.file}`);
|
|
133
|
+
}
|
|
134
|
+
if (done.length > 0) {
|
|
135
|
+
const file = join(root, '.tewip', 'quarantined.txt');
|
|
136
|
+
const before = readQuarantined(root);
|
|
137
|
+
writeFileSync(file, [...new Set([...before, ...done])].join('\n') + '\n', 'utf8');
|
|
138
|
+
lines.push('', `Remembered in ${join('.tewip', 'quarantined.txt')}, so Tewip can tell you when each one is safe to bring back.`);
|
|
139
|
+
}
|
|
140
|
+
lines.push('', 'Nothing else in those files was changed. Review the diff before committing: a quarantined test is a decision, not a fix.', '');
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const titleOf = (testId) => testId.split(' > ').at(-1) ?? testId;
|
|
2
|
+
const fileOf = (testId, record) => record?.file ?? testId.split(' > ').find((p) => /\.(spec|test)\./.test(p)) ?? '';
|
|
3
|
+
/**
|
|
4
|
+
* A test's story across the kept runs. `records` are the failures Tewip diagnosed; `runs` say how
|
|
5
|
+
* often each test ran at all, which is what turns a count into a rate.
|
|
6
|
+
*/
|
|
7
|
+
export function flakyTests(records, runs, options = {}) {
|
|
8
|
+
const { since = '', minRuns = 2, alwaysFailsAbove = 0.8 } = options;
|
|
9
|
+
const inWindow = runs.filter((r) => (r.startedAt ?? '') >= since);
|
|
10
|
+
const runIds = new Set(inWindow.map((r) => r.runId));
|
|
11
|
+
const branchOf = new Map(inWindow.map((r) => [r.runId, r.branch ?? '']));
|
|
12
|
+
const failures = records.filter((r) => runIds.has(r.runId) || (!r.runId && !since));
|
|
13
|
+
const ranIn = new Map();
|
|
14
|
+
for (const run of inWindow)
|
|
15
|
+
for (const testId of Object.keys(run.attempts ?? {}))
|
|
16
|
+
ranIn.set(testId, (ranIn.get(testId) ?? 0) + 1);
|
|
17
|
+
const byTest = new Map();
|
|
18
|
+
for (const record of failures)
|
|
19
|
+
byTest.set(record.testId, [...(byTest.get(record.testId) ?? []), record]);
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const [testId, all] of byTest) {
|
|
22
|
+
const runsFailed = new Set(all.map((r) => r.runId));
|
|
23
|
+
// A retry that passed: the same test, same run, a later attempt that is not in the failures.
|
|
24
|
+
const attemptsByRun = new Map();
|
|
25
|
+
for (const r of all)
|
|
26
|
+
attemptsByRun.set(r.runId, [...(attemptsByRun.get(r.runId) ?? []), r]);
|
|
27
|
+
let recovered = 0;
|
|
28
|
+
for (const [runId, attempts] of attemptsByRun) {
|
|
29
|
+
const ran = inWindow.find((run) => run.runId === runId)?.attempts?.[testId] ?? attempts.length;
|
|
30
|
+
if (ran > attempts.length)
|
|
31
|
+
recovered++;
|
|
32
|
+
}
|
|
33
|
+
const counts = new Map();
|
|
34
|
+
for (const r of all)
|
|
35
|
+
counts.set(r.diagnosis?.category ?? 'UNKNOWN', (counts.get(r.diagnosis?.category ?? 'UNKNOWN') ?? 0) + 1);
|
|
36
|
+
const categories = [...counts].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count);
|
|
37
|
+
const times = all.map((r) => r.timestamp).sort();
|
|
38
|
+
const branches = [...new Set([...runsFailed].map((id) => branchOf.get(id) ?? '').filter(Boolean))];
|
|
39
|
+
const totalRuns = ranIn.get(testId) ?? runsFailed.size;
|
|
40
|
+
const test = {
|
|
41
|
+
testId,
|
|
42
|
+
title: titleOf(testId),
|
|
43
|
+
file: fileOf(testId, all[0]),
|
|
44
|
+
failedRuns: runsFailed.size,
|
|
45
|
+
totalRuns,
|
|
46
|
+
failedAttempts: all.length,
|
|
47
|
+
wastedMs: all.reduce((sum, r) => sum + (r.duration ?? 0), 0),
|
|
48
|
+
recoveredOnRetry: recovered,
|
|
49
|
+
branches,
|
|
50
|
+
categories,
|
|
51
|
+
firstSeen: times[0] ?? '',
|
|
52
|
+
lastSeen: times.at(-1) ?? '',
|
|
53
|
+
verdict: 'watch',
|
|
54
|
+
why: '',
|
|
55
|
+
};
|
|
56
|
+
Object.assign(test, decide(test, { minRuns, alwaysFailsAbove }));
|
|
57
|
+
out.push(test);
|
|
58
|
+
}
|
|
59
|
+
return out.sort((a, b) => b.wastedMs - a.wastedMs || b.failedRuns - a.failedRuns);
|
|
60
|
+
}
|
|
61
|
+
const share = (test) => (test.totalRuns > 0 ? test.failedRuns / test.totalRuns : 1);
|
|
62
|
+
const flakyShare = (test) => {
|
|
63
|
+
const flaky = test.categories.find((c) => c.category === 'TIMING')?.count ?? 0;
|
|
64
|
+
return test.failedAttempts > 0 ? flaky / test.failedAttempts : 0;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* The decision, and why. Three rules, all conservative:
|
|
68
|
+
* a test that mostly fails is broken, not flaky - hiding it hides a real problem;
|
|
69
|
+
* a test whose failures Tewip read as the application being wrong is never quarantined;
|
|
70
|
+
* quarantine needs the test to have failed in several runs, and to be unreliable rather than
|
|
71
|
+
* consistently wrong.
|
|
72
|
+
*/
|
|
73
|
+
function decide(test, { minRuns, alwaysFailsAbove }) {
|
|
74
|
+
const rate = share(test);
|
|
75
|
+
const percent = `${Math.round(rate * 100)}% of the ${test.totalRuns} run(s) it was in`;
|
|
76
|
+
const realBugs = test.categories.find((c) => c.category === 'REAL_BUG')?.count ?? 0;
|
|
77
|
+
if (realBugs > 0 && realBugs >= test.failedAttempts / 2) {
|
|
78
|
+
return { verdict: 'fix-now', why: `${realBugs} of its ${test.failedAttempts} failures looked like the application being wrong, not the test. Quarantining it would hide a bug.` };
|
|
79
|
+
}
|
|
80
|
+
if (rate > alwaysFailsAbove && test.totalRuns >= minRuns) {
|
|
81
|
+
return { verdict: 'fix-now', why: `it fails in ${percent}. That is not flakiness, it is a test that does not work - hiding it would hide whatever it is telling you.` };
|
|
82
|
+
}
|
|
83
|
+
if (test.failedRuns >= minRuns && flakyShare(test) >= 0.5) {
|
|
84
|
+
const spread = test.branches.length > 1 ? ` It failed on ${test.branches.length} different branches, so it is the test rather than one person's change.` : '';
|
|
85
|
+
return { verdict: 'quarantine', why: `it failed in ${percent}, and Tewip read most of those as the test being unreliable.${spread}` };
|
|
86
|
+
}
|
|
87
|
+
return { verdict: 'watch', why: `it failed in ${percent}: not often enough, or not consistently enough one thing, to act on yet.` };
|
|
88
|
+
}
|
|
89
|
+
/** What a week of this costs, so the number means something to whoever pays for CI. */
|
|
90
|
+
export function weeklyCost(tests, days) {
|
|
91
|
+
const wasted = tests.reduce((sum, t) => sum + t.wastedMs, 0);
|
|
92
|
+
const attempts = tests.reduce((sum, t) => sum + t.failedAttempts, 0);
|
|
93
|
+
const weeks = Math.max(days / 7, 0.1);
|
|
94
|
+
return { minutes: Math.round(wasted / 60_000 / weeks), attempts: Math.round(attempts / weeks) };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Tests that were quarantined and have stopped failing. Nobody ever takes a test out of quarantine,
|
|
98
|
+
* so this looks for the ones that have earned it: no failure in the window, and they ran.
|
|
99
|
+
*/
|
|
100
|
+
export function readyToRelease(quarantined, records, runs, since) {
|
|
101
|
+
const failed = new Set(records.filter((r) => r.timestamp >= since).map((r) => r.testId));
|
|
102
|
+
const ran = new Set(runs.filter((r) => (r.startedAt ?? '') >= since).flatMap((r) => Object.keys(r.attempts ?? {})));
|
|
103
|
+
return quarantined.filter((testId) => ran.has(testId) && !failed.has(testId));
|
|
104
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Which framework a test file belongs to, from its name and its contents. */
|
|
2
|
+
export function frameworkOf(file, source) {
|
|
3
|
+
if (/\.py$/.test(file))
|
|
4
|
+
return 'pytest';
|
|
5
|
+
if (/\.java$|\.kt$/.test(file))
|
|
6
|
+
return 'junit';
|
|
7
|
+
if (/\.cs$/.test(file))
|
|
8
|
+
return 'dotnet';
|
|
9
|
+
if (/_test\.go$/.test(file))
|
|
10
|
+
return 'go';
|
|
11
|
+
if (/@playwright\/test|from ['"]playwright/.test(source))
|
|
12
|
+
return 'playwright';
|
|
13
|
+
if (/from ['"]vitest['"]|\bvi\./.test(source))
|
|
14
|
+
return 'vitest';
|
|
15
|
+
if (/@jest\/globals|jest\./.test(source))
|
|
16
|
+
return 'jest';
|
|
17
|
+
if (/\brequire\(['"]mocha|describe\(/.test(source))
|
|
18
|
+
return 'mocha';
|
|
19
|
+
return /\.[cm]?[jt]sx?$/.test(file) ? 'jest' : null;
|
|
20
|
+
}
|
|
21
|
+
const MARK = {
|
|
22
|
+
playwright: (title) => `test.fixme(true, ${JSON.stringify(`quarantined by Tewip: ${title}`)});`,
|
|
23
|
+
jest: () => 'test.skip',
|
|
24
|
+
vitest: () => 'test.skip',
|
|
25
|
+
mocha: () => 'it.skip',
|
|
26
|
+
pytest: (title) => `@pytest.mark.skip(reason=${JSON.stringify(`quarantined by Tewip: ${title}`)})`,
|
|
27
|
+
junit: (title) => `@Disabled(${JSON.stringify(`quarantined by Tewip: ${title}`)})`,
|
|
28
|
+
dotnet: (title) => `[Fact(Skip = ${JSON.stringify(`quarantined by Tewip: ${title}`)})]`,
|
|
29
|
+
go: () => 't.Skip("quarantined by Tewip")',
|
|
30
|
+
};
|
|
31
|
+
/** What to write, and the note that goes above it. */
|
|
32
|
+
export function quarantineFor(test, framework) {
|
|
33
|
+
const rate = test.totalRuns > 0 ? `${test.failedRuns} of ${test.totalRuns} runs` : `${test.failedRuns} run(s)`;
|
|
34
|
+
return {
|
|
35
|
+
line: MARK[framework](test.title),
|
|
36
|
+
comment: [
|
|
37
|
+
`Quarantined by Tewip on ${new Date().toISOString().slice(0, 10)}: failed in ${rate}, ${Math.round(test.wastedMs / 60_000)} CI minute(s) lost.`,
|
|
38
|
+
test.branches.length > 1 ? `It failed on ${test.branches.length} branches, so it is the test and not one change.` : '',
|
|
39
|
+
'Take this line out when the cause is fixed. `tewip flaky` will say when it has stopped failing.',
|
|
40
|
+
].filter(Boolean),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The file with the test quarantined, or null when the test could not be found in it. Only the one
|
|
45
|
+
* test named is touched; everything else in the file stays byte for byte as it was.
|
|
46
|
+
*/
|
|
47
|
+
export function applyQuarantine(source, test, framework) {
|
|
48
|
+
const nl = source.includes('\r\n') ? '\r\n' : '\n';
|
|
49
|
+
const lines = source.split(nl);
|
|
50
|
+
const quoted = test.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
const declaration = new RegExp(`^(\\s*)((?:async\\s+)?(?:export\\s+)?(?:test|it|def test_|public\\s+void|func\\s+Test)\\b[^\\n]*${quoted})`, 'i');
|
|
52
|
+
const index = lines.findIndex((line) => declaration.test(line) || line.includes(`"${test.title}"`) || line.includes(`'${test.title}'`));
|
|
53
|
+
if (index < 0)
|
|
54
|
+
return null;
|
|
55
|
+
if (lines.slice(Math.max(0, index - 4), index + 1).some((l) => /quarantined by tewip/i.test(l)))
|
|
56
|
+
return source; // already done
|
|
57
|
+
const indent = (lines[index].match(/^\s*/) ?? [''])[0];
|
|
58
|
+
const { line, comment } = quarantineFor(test, framework);
|
|
59
|
+
const commentMark = framework === 'pytest' ? '#' : '//';
|
|
60
|
+
const note = comment.map((c) => `${indent}${commentMark} ${c}`);
|
|
61
|
+
// Where the mark goes differs: an annotation sits above the test, a skip replaces the call.
|
|
62
|
+
if (framework === 'jest' || framework === 'vitest' || framework === 'mocha') {
|
|
63
|
+
const replaced = lines[index].replace(/\b(test|it)(\.\w+)?\(/, `${line}(`);
|
|
64
|
+
if (replaced === lines[index])
|
|
65
|
+
return null;
|
|
66
|
+
return [...lines.slice(0, index), ...note, replaced, ...lines.slice(index + 1)].join(nl);
|
|
67
|
+
}
|
|
68
|
+
if (framework === 'playwright') {
|
|
69
|
+
// Playwright's fixme goes inside the test body, so the test still reports itself as skipped.
|
|
70
|
+
const body = lines[index].includes('=>') ? index : -1;
|
|
71
|
+
if (body < 0)
|
|
72
|
+
return null;
|
|
73
|
+
return [...lines.slice(0, index), ...note, lines[index], `${indent} ${line}`, ...lines.slice(index + 1)].join(nl);
|
|
74
|
+
}
|
|
75
|
+
// pytest, JUnit, .NET: the annotation goes on its own line above the test.
|
|
76
|
+
return [...lines.slice(0, index), ...note, `${indent}${line}`, ...lines.slice(index)].join(nl);
|
|
77
|
+
}
|
|
@@ -15,8 +15,8 @@ import { importBlobReportDir } from '../import/blob.js';
|
|
|
15
15
|
import { llmFromEnv } from '../llm/client.js';
|
|
16
16
|
import { buildItems } from '../../outlets/items.js';
|
|
17
17
|
import { ciFacts } from '../ci/platform.js';
|
|
18
|
-
import { Jira, jiraFromEnv, jiraHelp } from './jira.js';
|
|
19
18
|
import { TEWIP_LABEL } from './jira-command.js';
|
|
19
|
+
import { trackerFromEnv, trackerHelp } from './trackers.js';
|
|
20
20
|
/** Bugs Tewip filed carry this as well, so a second run finds them and does not file again. */
|
|
21
21
|
export const BUG_LABEL = 'tewip-bug';
|
|
22
22
|
const isBlobDir = (path) => statSync(path).isDirectory() && readdirSync(path).some((f) => /^report.*\.zip$/.test(f));
|
|
@@ -59,11 +59,11 @@ export function bugSummary(item) {
|
|
|
59
59
|
return (same ? item.title : `${item.title} (${where})`).replace(/\s+/g, ' ').slice(0, 250);
|
|
60
60
|
}
|
|
61
61
|
export async function runBugs(cmd) {
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
if (cmd.create && !
|
|
66
|
-
throw new Error(`filing a bug needs a
|
|
62
|
+
// Jira, Rally or GitHub Issues - whichever this team uses. A dry run answers the question people
|
|
63
|
+
// ask first ("what would it file?") and needs no account at all; only --create writes.
|
|
64
|
+
const chosen = trackerFromEnv(cmd.projectKey);
|
|
65
|
+
if (cmd.create && !chosen?.tracker.canWrite())
|
|
66
|
+
throw new Error(`filing a bug needs a tracker that can be written to; showing what would be filed does not.\n${trackerHelp()}`);
|
|
67
67
|
const path = resolve(cmd.report);
|
|
68
68
|
if (!existsSync(path))
|
|
69
69
|
throw new Error(`no such report: ${cmd.report}`);
|
|
@@ -87,41 +87,36 @@ export async function runBugs(cmd) {
|
|
|
87
87
|
console.log(`${items.length} failure(s), and none of them is a real bug Tewip is confident about (${kinds}). Nothing was filed, which is the point.`);
|
|
88
88
|
return [];
|
|
89
89
|
}
|
|
90
|
-
const
|
|
91
|
-
let type = 'Bug';
|
|
90
|
+
const tracker = chosen?.tracker.canWrite() ? chosen.tracker : null;
|
|
92
91
|
let existing = [];
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!type)
|
|
97
|
-
throw new Error(`no issue type available in ${cmd.projectKey}`);
|
|
98
|
-
existing = await jira.findByLabel(cmd.projectKey, BUG_LABEL);
|
|
92
|
+
if (tracker && chosen) {
|
|
93
|
+
lines.push(`Tracker: ${tracker.name}`, '');
|
|
94
|
+
existing = await tracker.mine(chosen.project, BUG_LABEL);
|
|
99
95
|
}
|
|
100
96
|
else {
|
|
101
|
-
lines.push('
|
|
97
|
+
lines.push('No tracker was contacted: this is what would be filed. Set one up (see `tewip bugs` without --create for the settings), then add --create.', '');
|
|
102
98
|
}
|
|
103
99
|
let filed = 0;
|
|
104
100
|
for (const item of bugs.slice(0, cmd.max)) {
|
|
105
101
|
const summary = bugSummary(item);
|
|
106
|
-
const already = existing.find((i) => i.
|
|
102
|
+
const already = existing.find((i) => i.title === summary);
|
|
107
103
|
if (already) {
|
|
108
104
|
lines.push(` ${item.title} already filed as ${already.key}`);
|
|
109
|
-
if (cmd.create &&
|
|
110
|
-
await
|
|
105
|
+
if (cmd.create && tracker) {
|
|
106
|
+
await tracker.comment(already.key, seenAgain(item, facts.runUrl)).catch(() => lines.push(` (could not add the note to ${already.key})`));
|
|
111
107
|
lines.push(` added a note that it happened again`);
|
|
112
108
|
}
|
|
113
109
|
continue;
|
|
114
110
|
}
|
|
115
|
-
if (!cmd.create || !
|
|
111
|
+
if (!cmd.create || !tracker || !chosen) {
|
|
116
112
|
lines.push(` would file: ${summary}`);
|
|
117
113
|
lines.push(` because: ${item.diagnosis.category} at ${item.diagnosis.confidence.toFixed(2)} - ${item.errorHead.replace(/\s+/g, ' ').slice(0, 100)}`);
|
|
118
114
|
continue;
|
|
119
115
|
}
|
|
120
|
-
const key = await
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
description: bugBody(item, facts.runUrl),
|
|
116
|
+
const key = await tracker.create(chosen.project, {
|
|
117
|
+
kind: 'bug',
|
|
118
|
+
title: summary,
|
|
119
|
+
body: bugBody(item, facts.runUrl),
|
|
125
120
|
labels: [TEWIP_LABEL, BUG_LABEL, `tewip-${item.component.replace(/[^\w-]+/g, '-')}`.slice(0, 40)],
|
|
126
121
|
});
|
|
127
122
|
lines.push(` filed ${key}: ${summary}`);
|
|
@@ -129,7 +124,7 @@ export async function runBugs(cmd) {
|
|
|
129
124
|
}
|
|
130
125
|
const left = items.length - bugs.length;
|
|
131
126
|
lines.push('', `${bugs.length} of ${items.length} failure(s) look like real bugs; ${left} were flaky tests, environment or test data and were deliberately not filed.`);
|
|
132
|
-
lines.push(cmd.create ? `${filed} ticket(s) created. Tewip never moves, assigns or closes one.` : 'Nothing was created. Add --create to file them.');
|
|
127
|
+
lines.push(cmd.create ? `${filed} ticket(s) created in ${tracker?.name}. Tewip never moves, assigns or closes one.` : 'Nothing was created. Add --create to file them.');
|
|
133
128
|
console.log(lines.join('\n'));
|
|
134
129
|
return bugs;
|
|
135
130
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// GitHub Issues, for the many teams whose backlog is simply their repository.
|
|
2
|
+
//
|
|
3
|
+
// It uses the token in the environment, or the `gh` CLI login the person already has, so a team on
|
|
4
|
+
// GitHub needs no new account and no new secret. The restraint is the same: create, comment on what
|
|
5
|
+
// it created, find what it filed before by its label. It never closes an issue, never assigns
|
|
6
|
+
// anyone, and never touches an issue somebody else opened.
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
export function githubIssuesFromEnv(env = process.env) {
|
|
9
|
+
const repo = (env.TEWIP_GITHUB_REPO ?? env.GITHUB_REPOSITORY ?? '').trim();
|
|
10
|
+
if (!repo)
|
|
11
|
+
return null;
|
|
12
|
+
return { repo, token: (env.TEWIP_GITHUB_TOKEN ?? env.GITHUB_TOKEN ?? env.GH_TOKEN ?? '').trim() || ghCliToken() };
|
|
13
|
+
}
|
|
14
|
+
/** The token the `gh` CLI already holds, so a person logged in there needs nothing else. */
|
|
15
|
+
function ghCliToken() {
|
|
16
|
+
const r = spawnSync('gh auth token', { shell: true, encoding: 'utf8', timeout: 20_000 });
|
|
17
|
+
return r.status === 0 ? (r.stdout ?? '').trim() : '';
|
|
18
|
+
}
|
|
19
|
+
/** The kind of work is a label here: GitHub has one issue type and many labels. */
|
|
20
|
+
const KIND_LABEL = { 'test-case': 'test-case', bug: 'bug', story: 'story', epic: 'epic' };
|
|
21
|
+
export class GitHubIssues {
|
|
22
|
+
name = 'GitHub Issues';
|
|
23
|
+
config;
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
}
|
|
27
|
+
canWrite() {
|
|
28
|
+
return Boolean(this.config.repo && this.config.token);
|
|
29
|
+
}
|
|
30
|
+
async request(method, path, body) {
|
|
31
|
+
const res = await fetch(`https://api.github.com${path}`, {
|
|
32
|
+
method,
|
|
33
|
+
headers: {
|
|
34
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
35
|
+
Accept: 'application/vnd.github+json',
|
|
36
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
37
|
+
'User-Agent': 'tewip',
|
|
38
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
39
|
+
},
|
|
40
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`GitHub ${method} ${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
44
|
+
return (res.status === 204 ? undefined : await res.json());
|
|
45
|
+
}
|
|
46
|
+
async mine(_project, label) {
|
|
47
|
+
// Closed ones count too: an issue Tewip filed and a person closed must not be filed again.
|
|
48
|
+
const issues = await this.request('GET', `/repos/${this.config.repo}/issues?labels=${encodeURIComponent(label)}&state=all&per_page=100`);
|
|
49
|
+
return issues.map((i) => ({ key: `#${i.number}`, title: i.title, url: i.html_url }));
|
|
50
|
+
}
|
|
51
|
+
async create(_project, item) {
|
|
52
|
+
const created = await this.request('POST', `/repos/${this.config.repo}/issues`, {
|
|
53
|
+
title: item.title.slice(0, 250),
|
|
54
|
+
body: item.body.slice(0, 60_000),
|
|
55
|
+
labels: [...new Set([...(item.labels ?? []), KIND_LABEL[item.kind], ...(item.priority ? [`priority:${item.priority.toLowerCase()}`] : [])])],
|
|
56
|
+
});
|
|
57
|
+
return `#${created.number}`;
|
|
58
|
+
}
|
|
59
|
+
async comment(key, text) {
|
|
60
|
+
const number = key.replace('#', '');
|
|
61
|
+
await this.request('POST', `/repos/${this.config.repo}/issues/${number}/comments`, { body: text.slice(0, 60_000) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { priorityWord } from './tracker.js';
|
|
2
|
+
/** Rally's own name for each kind of item, and the endpoint that creates it. */
|
|
3
|
+
const TYPE = {
|
|
4
|
+
'test-case': 'testcase',
|
|
5
|
+
bug: 'defect',
|
|
6
|
+
story: 'hierarchicalrequirement',
|
|
7
|
+
epic: 'portfolioitem/feature',
|
|
8
|
+
};
|
|
9
|
+
/** The field that holds the words, which differs by type. */
|
|
10
|
+
const BODY_FIELD = { 'test-case': 'Description', bug: 'Description', story: 'Description', epic: 'Description' };
|
|
11
|
+
export function rallyFromEnv(env = process.env) {
|
|
12
|
+
const apiKey = env.TEWIP_RALLY_KEY?.trim();
|
|
13
|
+
if (!apiKey)
|
|
14
|
+
return null;
|
|
15
|
+
return {
|
|
16
|
+
baseUrl: (env.TEWIP_RALLY_URL ?? 'https://rally1.rallydev.com').trim().replace(/\/+$/, ''),
|
|
17
|
+
apiKey,
|
|
18
|
+
project: (env.TEWIP_RALLY_PROJECT ?? '').trim(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export class Rally {
|
|
22
|
+
name = 'Rally';
|
|
23
|
+
config;
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
}
|
|
27
|
+
canWrite() {
|
|
28
|
+
return Boolean(this.config.apiKey && this.config.project);
|
|
29
|
+
}
|
|
30
|
+
async request(method, path, body) {
|
|
31
|
+
const res = await fetch(`${this.config.baseUrl}/slm/webservice/v2.0${path}`, {
|
|
32
|
+
method,
|
|
33
|
+
headers: { ZSESSIONID: this.config.apiKey, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
34
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok)
|
|
37
|
+
throw new Error(`Rally ${method} ${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
38
|
+
const json = (await res.json());
|
|
39
|
+
// Rally answers 200 with the errors inside the body, so they have to be read out of it.
|
|
40
|
+
const errors = json?.CreateResult?.Errors ?? json?.QueryResult?.Errors ?? json?.OperationResult?.Errors ?? [];
|
|
41
|
+
if (errors.length > 0)
|
|
42
|
+
throw new Error(`Rally ${method} ${path}: ${errors.join('; ').slice(0, 300)}`);
|
|
43
|
+
return json;
|
|
44
|
+
}
|
|
45
|
+
/** Items Tewip filed before, found by the tag it puts on everything it creates. */
|
|
46
|
+
async mine(project, label) {
|
|
47
|
+
const found = [];
|
|
48
|
+
for (const kind of ['testcase', 'defect', 'hierarchicalrequirement']) {
|
|
49
|
+
const query = encodeURIComponent(`(Tags.Name = "${label}")`);
|
|
50
|
+
const scope = project || this.config.project;
|
|
51
|
+
const path = `/${kind}?query=${query}&fetch=FormattedID,Name&pagesize=200${scope ? `&project=${encodeURIComponent(scope)}` : ''}`;
|
|
52
|
+
try {
|
|
53
|
+
const body = await this.request('GET', path);
|
|
54
|
+
for (const r of body.QueryResult?.Results ?? [])
|
|
55
|
+
found.push({ key: String(r.FormattedID ?? r._ref ?? ''), title: String(r.Name ?? ''), url: r._ref });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// A workspace without that type is normal; the others still answer.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
async create(project, item) {
|
|
64
|
+
const type = TYPE[item.kind];
|
|
65
|
+
const fields = {
|
|
66
|
+
Name: item.title.slice(0, 250),
|
|
67
|
+
[BODY_FIELD[item.kind]]: item.body.slice(0, 32_000),
|
|
68
|
+
Project: project || this.config.project,
|
|
69
|
+
};
|
|
70
|
+
if (item.priority)
|
|
71
|
+
fields[item.kind === 'bug' ? 'Priority' : 'Risk'] = priorityWord('rally', item.priority);
|
|
72
|
+
if (item.parent)
|
|
73
|
+
fields[item.kind === 'story' ? 'PortfolioItem' : 'WorkProduct'] = item.parent;
|
|
74
|
+
const key = type.split('/')[0];
|
|
75
|
+
const created = await this.request('POST', `/${type}/create`, { [capitalise(key)]: fields });
|
|
76
|
+
const made = created.CreateResult?.Object;
|
|
77
|
+
return String(made?.FormattedID ?? made?._ref ?? '');
|
|
78
|
+
}
|
|
79
|
+
/** A note on an item Tewip created. Rally calls it a ConversationPost. */
|
|
80
|
+
async comment(key, text) {
|
|
81
|
+
await this.request('POST', '/conversationpost/create', { ConversationPost: { Artifact: key, Text: text.slice(0, 4000) } });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const capitalise = (word) => word.charAt(0).toUpperCase() + word.slice(1);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const TEWIP_LABEL = 'tewip';
|
|
2
|
+
/** Every tracker's own word for a priority, so a P0 does not arrive as "P0" in a tool that says "High". */
|
|
3
|
+
export const PRIORITY_WORDS = {
|
|
4
|
+
jira: { P0: 'Highest', P1: 'High', P2: 'Medium' },
|
|
5
|
+
rally: { P0: 'Resolve Immediately', P1: 'High Attention', P2: 'Normal' },
|
|
6
|
+
github: { P0: 'priority:high', P1: 'priority:medium', P2: 'priority:low' },
|
|
7
|
+
};
|
|
8
|
+
export function priorityWord(tracker, priority) {
|
|
9
|
+
return PRIORITY_WORDS[tracker]?.[priority] ?? priority;
|
|
10
|
+
}
|
|
11
|
+
/** Which tracker the settings point at. Jira stays the default, because most teams are there. */
|
|
12
|
+
export function trackerNameFromEnv(env = process.env) {
|
|
13
|
+
const named = (env.TEWIP_TRACKER ?? '').trim().toLowerCase();
|
|
14
|
+
if (named === 'rally' || named === 'github' || named === 'jira')
|
|
15
|
+
return named;
|
|
16
|
+
if (env.TEWIP_RALLY_KEY)
|
|
17
|
+
return 'rally';
|
|
18
|
+
if (env.TEWIP_GITHUB_REPO && !env.TEWIP_JIRA_URL)
|
|
19
|
+
return 'github';
|
|
20
|
+
return 'jira';
|
|
21
|
+
}
|
|
22
|
+
export function trackerHelp() {
|
|
23
|
+
return [
|
|
24
|
+
'No tracker is set up. Tewip files work items into whichever of these you use:',
|
|
25
|
+
'',
|
|
26
|
+
' Jira (Cloud, Server or Data Center)',
|
|
27
|
+
' TEWIP_JIRA_URL=https://your-team.atlassian.net TEWIP_JIRA_EMAIL=you@company.com TEWIP_JIRA_TOKEN=...',
|
|
28
|
+
'',
|
|
29
|
+
' Rally (Broadcom, formerly CA Agile Central)',
|
|
30
|
+
' TEWIP_RALLY_KEY=_abc123... TEWIP_RALLY_PROJECT=/project/12345 (TEWIP_RALLY_URL for on-premise)',
|
|
31
|
+
'',
|
|
32
|
+
' GitHub Issues',
|
|
33
|
+
' TEWIP_GITHUB_REPO=owner/name TEWIP_GITHUB_TOKEN=ghp_... (or be logged in with the gh CLI)',
|
|
34
|
+
'',
|
|
35
|
+
'Set TEWIP_TRACKER=jira|rally|github when more than one is configured.',
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Picking the tracker a team actually uses, and making Jira look like the others.
|
|
2
|
+
//
|
|
3
|
+
// Everything above this line in Tewip talks to `Tracker`, so adding Linear or Azure Boards later is
|
|
4
|
+
// one file and no changes anywhere else - and the promises (create, never move, never assign, never
|
|
5
|
+
// close, never file twice) hold for all of them by construction.
|
|
6
|
+
import { Jira, jiraFromEnv } from './jira.js';
|
|
7
|
+
import { Rally, rallyFromEnv } from './rally.js';
|
|
8
|
+
import { GitHubIssues, githubIssuesFromEnv } from './github-issues.js';
|
|
9
|
+
import { priorityWord, trackerHelp, trackerNameFromEnv } from './tracker.js';
|
|
10
|
+
/** Jira, wearing the same interface as the rest. */
|
|
11
|
+
export class JiraTracker {
|
|
12
|
+
name = 'Jira';
|
|
13
|
+
jira;
|
|
14
|
+
hasToken;
|
|
15
|
+
/** Issue types this project really has, read once. */
|
|
16
|
+
types = null;
|
|
17
|
+
constructor(jira, hasToken) {
|
|
18
|
+
this.jira = jira;
|
|
19
|
+
this.hasToken = hasToken;
|
|
20
|
+
}
|
|
21
|
+
canWrite() {
|
|
22
|
+
return this.hasToken;
|
|
23
|
+
}
|
|
24
|
+
async mine(project, label) {
|
|
25
|
+
return (await this.jira.findByLabel(project, label)).map((i) => ({ key: i.key, title: i.summary }));
|
|
26
|
+
}
|
|
27
|
+
/** Jira projects differ in which types exist, so the nearest one that does is used. */
|
|
28
|
+
async typeFor(project, kind) {
|
|
29
|
+
this.types ??= await this.jira.issueTypes(project);
|
|
30
|
+
const wanted = {
|
|
31
|
+
'test-case': ['Test', 'Test Case', 'Task'],
|
|
32
|
+
bug: ['Bug', 'Defect', 'Task'],
|
|
33
|
+
story: ['Story', 'User Story', 'Task'],
|
|
34
|
+
epic: ['Epic', 'Feature', 'Task'],
|
|
35
|
+
};
|
|
36
|
+
const found = wanted[kind].find((t) => this.types.includes(t)) ?? this.types[0];
|
|
37
|
+
if (!found)
|
|
38
|
+
throw new Error(`no issue type available in ${project}`);
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
async create(project, item) {
|
|
42
|
+
return this.jira.createIssue({
|
|
43
|
+
projectKey: project,
|
|
44
|
+
type: await this.typeFor(project, item.kind),
|
|
45
|
+
summary: item.title,
|
|
46
|
+
description: item.body,
|
|
47
|
+
labels: item.labels,
|
|
48
|
+
parentKey: item.parent,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async comment(key, text) {
|
|
52
|
+
await this.jira.comment(key, text);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The tracker the settings point at, or null when none is configured. A tracker that can only be
|
|
57
|
+
* read (a public Jira, or GitHub without a token) still comes back: a dry run needs no write.
|
|
58
|
+
*/
|
|
59
|
+
export function trackerFromEnv(projectKey = '', env = process.env) {
|
|
60
|
+
const id = trackerNameFromEnv(env);
|
|
61
|
+
if (id === 'rally') {
|
|
62
|
+
const config = rallyFromEnv(env);
|
|
63
|
+
return config ? { tracker: new Rally(config), project: projectKey || config.project, id } : null;
|
|
64
|
+
}
|
|
65
|
+
if (id === 'github') {
|
|
66
|
+
const config = githubIssuesFromEnv(env);
|
|
67
|
+
return config ? { tracker: new GitHubIssues(config), project: projectKey || config.repo, id } : null;
|
|
68
|
+
}
|
|
69
|
+
const config = jiraFromEnv();
|
|
70
|
+
return config ? { tracker: new JiraTracker(new Jira(config), Boolean(config.token)), project: projectKey, id } : null;
|
|
71
|
+
}
|
|
72
|
+
export { priorityWord, trackerHelp };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"test-failures",
|