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 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
- const config = jiraFromEnv();
63
- // A dry run answers the question people ask first - "what would it file?" - and needs no account
64
- // at all. Only --create talks to Jira.
65
- if (cmd.create && !config?.token)
66
- throw new Error(`filing a bug needs a token: showing what would be filed does not.\n${jiraHelp()}`);
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 jira = config?.token ? new Jira(config) : null;
91
- let type = 'Bug';
90
+ const tracker = chosen?.tracker.canWrite() ? chosen.tracker : null;
92
91
  let existing = [];
93
- if (jira) {
94
- const types = await jira.issueTypes(cmd.projectKey);
95
- type = ['Bug', 'Defect', 'Task'].find((t) => types.includes(t)) ?? types[0];
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('Jira was not contacted: this is what would be filed. Set TEWIP_JIRA_URL, TEWIP_JIRA_EMAIL and TEWIP_JIRA_TOKEN, then add --create.', '');
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.summary === summary);
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 && jira) {
110
- await jira.comment(already.key, seenAgain(item, facts.runUrl)).catch(() => lines.push(` (could not add the note to ${already.key})`));
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 || !jira) {
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 jira.createIssue({
121
- projectKey: cmd.projectKey,
122
- type,
123
- summary,
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.8.1",
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",