tewip-ai 0.7.1 → 0.8.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,22 @@ 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
+ ## Bugs filed back, and test cases where your team keeps them (Phase 10, completed)
170
+
171
+ ```bash
172
+ tewip bugs report.xml --project SHOP # shows what it would file; nothing is created
173
+ tewip bugs report.xml --project SHOP --create # files them
174
+ tewip jira plan.json --export xray --out cases.csv # or --export zephyr
175
+ ```
176
+
177
+ `tewip bugs` files a ticket only for failures **it** diagnosed as a real bug, with the error, the
178
+ evidence and the run link. A flaky test or a broken environment is deliberately not filed - that is
179
+ the point of diagnosing first. The same failure is never filed twice: the second sighting is a short
180
+ note on the ticket Tewip itself opened. A dry run needs no Jira account at all.
181
+
182
+ `--export xray|zephyr` writes the plan's scenarios as the CSV those tools import, one row per step.
183
+ No account, no network; importing stays your step.
184
+
169
185
  ## Security: which findings actually matter (Phase 9, new)
170
186
 
171
187
  ```bash
package/TEAM-GUIDE.md CHANGED
@@ -121,6 +121,8 @@ collects environment failures in one issue, and (optionally) opens a fix pull re
121
121
  | `tewip write` | Turns the plan's scenarios into real tests in your own framework, and keeps only the ones that pass repeatedly **and** fail when their check is broken on purpose | When you know what to test but not how to write it |
122
122
  | `tewip plan --issue PROJ-123` | Plans from the Jira ticket itself: its description and acceptance criteria | "Here's the story, what should we test?" |
123
123
  | `tewip jira plan.json --project PROJ` | Files the plan's scenarios as test-case tickets, linked to the story | Handing work to the team |
124
+ | `tewip bugs report.xml --project PROJ` | Files a bug for each failure that really is a bug, with its evidence; files nothing for flaky tests or environment problems | After a CI run with real failures |
125
+ | `tewip jira plan.json --export xray` | Writes the test cases as a CSV for Xray or Zephyr Scale | Your team keeps cases in a test-management tool |
124
126
  | `tewip secure [--url http://localhost:3000]` | Runs your dependency and secret scanners, checks the running app's headers, cookies and exposed files, then says which findings need someone now and which are noise or unreachable | Before a release, or in CI |
125
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 |
126
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 |
package/dist/cli/tewip.js CHANGED
@@ -38,6 +38,13 @@ Usage:
38
38
  File the plan's scenarios as test-case issues, linked to the
39
39
  story. Shows what it would create; --create actually files
40
40
  them, and the same scenario is never filed twice.
41
+ --export xray|zephyr Write the cases as a CSV for Xray or Zephyr Scale instead.
42
+ No account needed; importing stays your step.
43
+ tewip bugs <report> --project PROJ [--create] [--max 5]
44
+ File a bug ticket for each failure Tewip diagnosed as a real
45
+ bug, with its evidence and run link. A flaky test or a broken
46
+ environment is deliberately not filed, and the same failure is
47
+ never filed twice.
41
48
  tewip secure [--url http://localhost:3000] [--secrets-only] [--gate] [--out security.md]
42
49
  Runs the scanners this project already has (npm audit,
43
50
  pip-audit, osv-scanner) plus Tewip's own secret scan, then says
@@ -148,12 +155,20 @@ async function main() {
148
155
  case 'jira': {
149
156
  const planFile = args.find((a) => a.endsWith('.json'));
150
157
  const project = flag(args, 'project');
151
- if (!planFile || !project)
152
- throw new Error('usage: tewip jira <plan.json> --project PROJ [--issue PROJ-123] [--type "Test"] [--only QS-001,QS-002] [--create]');
158
+ // Writing a file for a test-management tool needs no project at all.
159
+ if (!planFile || (!project && !flag(args, 'export'))) {
160
+ throw new Error(['usage: tewip jira <plan.json> --project PROJ [--issue PROJ-123] [--only QS-001,QS-002] [--create]', ' or: tewip jira <plan.json> --export xray|zephyr [--out cases.csv]'].join('\n'));
161
+ }
153
162
  const { runJira } = await import('../engine/work/jira-command.js');
163
+ const exportAs = flag(args, 'export');
164
+ if (exportAs && exportAs !== 'xray' && exportAs !== 'zephyr')
165
+ throw new Error('--export takes xray or zephyr');
154
166
  await runJira({
155
167
  planFile,
156
- projectKey: project,
168
+ // An export needs no project at all; filing into Jira has already been checked to have one.
169
+ projectKey: project ?? '',
170
+ exportAs: exportAs,
171
+ out: flag(args, 'out'),
157
172
  type: flag(args, 'type'),
158
173
  issueKey: flag(args, 'issue'),
159
174
  create: args.includes('--create'),
@@ -161,6 +176,22 @@ async function main() {
161
176
  });
162
177
  return;
163
178
  }
179
+ case 'bugs': {
180
+ const report = args.find((a, i) => !a.startsWith('--') && !['--project', '--max'].includes(args[i - 1] ?? ''));
181
+ const project = flag(args, 'project');
182
+ if (!report || !project)
183
+ throw new Error('usage: tewip bugs <report.xml | reports-dir | blob-report-dir> --project PROJ [--create] [--max 5]');
184
+ process.env.TEWIP_LLM_CACHE ??= join(process.cwd(), '.tewip', 'llm-cache.jsonl');
185
+ const { runBugs } = await import('../engine/work/bug-command.js');
186
+ await runBugs({
187
+ root: process.cwd(),
188
+ report,
189
+ projectKey: project,
190
+ create: args.includes('--create'),
191
+ max: Math.max(1, Number(flag(args, 'max')) || 5),
192
+ });
193
+ return;
194
+ }
164
195
  case 'secure': {
165
196
  const { runSecure } = await import('../engine/secure/secure-command.js');
166
197
  const items = await runSecure({
@@ -0,0 +1,135 @@
1
+ // Phase 10, the third direction: a failure that really is a bug becomes a bug ticket, with the
2
+ // evidence that makes it one. A failure that is a flaky test or a broken environment does not -
3
+ // that is the whole point. Filing everything is what people already do, and it is why nobody reads
4
+ // the tickets.
5
+ //
6
+ // The same restraint as the rest: nothing is created without --create, the same failure is never
7
+ // filed twice (a second sighting is a comment on the ticket Tewip itself opened), and no ticket is
8
+ // ever moved, assigned or closed.
9
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
+ import { basename, dirname, join, resolve } from 'node:path';
11
+ import { loadCorrections } from '../corrections.js';
12
+ import { HistoryIndex } from '../history.js';
13
+ import { importJunit } from '../import/junit.js';
14
+ import { importBlobReportDir } from '../import/blob.js';
15
+ import { llmFromEnv } from '../llm/client.js';
16
+ import { buildItems } from '../../outlets/items.js';
17
+ import { ciFacts } from '../ci/platform.js';
18
+ import { Jira, jiraFromEnv, jiraHelp } from './jira.js';
19
+ import { TEWIP_LABEL } from './jira-command.js';
20
+ /** Bugs Tewip filed carry this as well, so a second run finds them and does not file again. */
21
+ export const BUG_LABEL = 'tewip-bug';
22
+ const isBlobDir = (path) => statSync(path).isDirectory() && readdirSync(path).some((f) => /^report.*\.zip$/.test(f));
23
+ /** The bug report a developer actually wants: what failed, what it did, and why Tewip believes it. */
24
+ export function bugBody(item, runUrl) {
25
+ return [
26
+ `The test "${item.title}" failed, and Tewip diagnosed a real bug in the application rather than a problem with the test.`,
27
+ '',
28
+ `Test: ${item.testId}`,
29
+ `File: ${item.file}`,
30
+ item.commit ? `Commit: ${item.commit}` : '',
31
+ runUrl ? `Run: ${runUrl}` : '',
32
+ '',
33
+ 'What the test saw:',
34
+ item.errorHead,
35
+ '',
36
+ `Diagnosis: ${item.diagnosis.category} at ${item.diagnosis.confidence.toFixed(2)} confidence (${item.source}).`,
37
+ '',
38
+ 'Evidence:',
39
+ ...item.evidence.slice(0, 6).map((line) => `- ${line}`),
40
+ '',
41
+ `Suggested next step: ${item.action.summary}`,
42
+ '',
43
+ 'Filed by Tewip because this failure looks like the application being wrong, not the test. If it',
44
+ 'is not, say so on the failure in CI (/triage CATEGORY #n reason) and Tewip will answer with your',
45
+ 'decision next time instead of its own.',
46
+ ]
47
+ .filter((line) => line !== '')
48
+ .join('\n');
49
+ }
50
+ /** A short line for a ticket that already exists: the same bug, seen again. */
51
+ export const seenAgain = (item, runUrl) => `Seen again: "${item.title}" failed in ${runUrl || 'another run'}${item.commit ? ` on ${item.commit.slice(0, 8)}` : ''}. Tewip still reads it as a real bug (${item.diagnosis.confidence.toFixed(2)}).`;
52
+ /**
53
+ * A title that stays the same across runs, so the same failure is recognised next time. The area is
54
+ * added only when it says something the title does not: "checkout (checkout)" helps nobody.
55
+ */
56
+ export function bugSummary(item) {
57
+ const where = (item.component || item.file || '').trim();
58
+ const same = !where || item.title.toLowerCase().includes(where.toLowerCase());
59
+ return (same ? item.title : `${item.title} (${where})`).replace(/\s+/g, ' ').slice(0, 250);
60
+ }
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()}`);
67
+ const path = resolve(cmd.report);
68
+ if (!existsSync(path))
69
+ throw new Error(`no such report: ${cmd.report}`);
70
+ const facts = ciFacts();
71
+ const name = basename(statSync(path).isDirectory() ? path : dirname(path)).replace(/[^\w.-]+/g, '-');
72
+ const historyDir = join(cmd.root, '.tewip', 'history', name);
73
+ const meta = { runId: facts.runId || `local-${Date.now()}`, gitSha: facts.sha, workspace: cmd.root };
74
+ const imported = isBlobDir(path) ? importBlobReportDir(path, meta, join(historyDir, 'files')) : importJunit(path, meta);
75
+ const index = new HistoryIndex(imported.records, [imported.summary]);
76
+ const items = await buildItems(imported.records, index, { dataset: `local: ${name}`, llm: llmFromEnv(), corrections: loadCorrections(historyDir) });
77
+ // Only what Tewip itself calls a bug, and only when it is confident enough to put a person's name
78
+ // on it. Everything else is deliberately left out.
79
+ const bugs = items.filter((i) => i.diagnosis.category === 'REAL_BUG' && i.diagnosis.confidence >= 0.8);
80
+ const lines = [`Report: ${cmd.report}`, `Project: ${cmd.projectKey}`, ''];
81
+ if (items.length === 0) {
82
+ console.log('Every test in that report passed. Nothing to file.');
83
+ return [];
84
+ }
85
+ if (bugs.length === 0) {
86
+ const kinds = [...new Set(items.map((i) => i.diagnosis.category))].join(', ');
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
+ return [];
89
+ }
90
+ const jira = config?.token ? new Jira(config) : null;
91
+ let type = 'Bug';
92
+ 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);
99
+ }
100
+ 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.', '');
102
+ }
103
+ let filed = 0;
104
+ for (const item of bugs.slice(0, cmd.max)) {
105
+ const summary = bugSummary(item);
106
+ const already = existing.find((i) => i.summary === summary);
107
+ if (already) {
108
+ 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})`));
111
+ lines.push(` added a note that it happened again`);
112
+ }
113
+ continue;
114
+ }
115
+ if (!cmd.create || !jira) {
116
+ lines.push(` would file: ${summary}`);
117
+ lines.push(` because: ${item.diagnosis.category} at ${item.diagnosis.confidence.toFixed(2)} - ${item.errorHead.replace(/\s+/g, ' ').slice(0, 100)}`);
118
+ continue;
119
+ }
120
+ const key = await jira.createIssue({
121
+ projectKey: cmd.projectKey,
122
+ type,
123
+ summary,
124
+ description: bugBody(item, facts.runUrl),
125
+ labels: [TEWIP_LABEL, BUG_LABEL, `tewip-${item.component.replace(/[^\w-]+/g, '-')}`.slice(0, 40)],
126
+ });
127
+ lines.push(` filed ${key}: ${summary}`);
128
+ filed++;
129
+ }
130
+ const left = items.length - bugs.length;
131
+ 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.');
133
+ console.log(lines.join('\n'));
134
+ return bugs;
135
+ }
@@ -5,7 +5,10 @@
5
5
  // scenario only once: every issue it creates carries a label, so a second run finds them and skips
6
6
  // what is already there rather than making duplicates.
7
7
  import { readFileSync } from 'node:fs';
8
+ import { mkdirSync, writeFileSync } from 'node:fs';
9
+ import { dirname, resolve } from 'node:path';
8
10
  import { Jira, jiraFromEnv, jiraHelp } from './jira.js';
11
+ import { exportTestCases, importHint } from './testcase-export.js';
9
12
  export const TEWIP_LABEL = 'tewip';
10
13
  /** One test case, written the way a person reads it rather than as JSON. */
11
14
  export function issueBody(scenario, feature, source) {
@@ -36,9 +39,25 @@ function chosen(plan, only) {
36
39
  return plan.scenarios.filter((s) => (wanted.size ? wanted.has(s.id.toUpperCase()) : !s.covered_by));
37
40
  }
38
41
  export async function runJira(cmd) {
42
+ const saved0 = JSON.parse(readFileSync(cmd.planFile, 'utf8'));
43
+ if (cmd.exportAs) {
44
+ // A file for Xray or Zephyr: no account, no network, and importing stays the person's step.
45
+ const chosenCases = chosen(saved0.plan, cmd.only);
46
+ if (chosenCases.length === 0)
47
+ throw new Error('every scenario in the plan is already covered by a test');
48
+ const csv = exportTestCases({ ...saved0.plan, scenarios: chosenCases }, saved0.feature, cmd.exportAs);
49
+ const file = resolve(cmd.out ?? `tewip-${cmd.exportAs}.csv`);
50
+ mkdirSync(dirname(file), { recursive: true });
51
+ writeFileSync(file, `${csv}\n`, 'utf8');
52
+ console.log(`${chosenCases.length} test case(s) for ${cmd.exportAs === 'xray' ? 'Xray' : 'Zephyr Scale'}.`);
53
+ console.log(importHint(cmd.exportAs, file));
54
+ return;
55
+ }
39
56
  const config = jiraFromEnv();
40
57
  if (!config)
41
58
  throw new Error(jiraHelp());
59
+ if (!config.token)
60
+ throw new Error(`filing test cases needs a token: reading a public tracker is free, writing to one is not.\n${jiraHelp()}`);
42
61
  const jira = new Jira(config);
43
62
  const saved = JSON.parse(readFileSync(cmd.planFile, 'utf8'));
44
63
  const scenarios = chosen(saved.plan, cmd.only);
@@ -109,6 +109,10 @@ export class Jira {
109
109
  }
110
110
  /** A description is a document tree on Cloud and a string of wiki markup everywhere else. */
111
111
  bodyFor(version, body) {
112
+ const asComment = body;
113
+ if (typeof asComment.body === 'string') {
114
+ return version === 2 ? body : { body: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: asComment.body.slice(0, 30_000) }] }] } };
115
+ }
112
116
  const fields = body.fields;
113
117
  if (!fields || typeof fields.description !== 'string')
114
118
  return body;
@@ -140,6 +144,13 @@ export class Jira {
140
144
  });
141
145
  return created.key;
142
146
  }
147
+ /**
148
+ * A note on a ticket Tewip itself opened - never on anyone else's. The caller finds the ticket by
149
+ * Tewip's own label, which is the only way it recognises its own work.
150
+ */
151
+ async comment(key, text) {
152
+ await this.request('POST', `/issue/${encodeURIComponent(key)}/comment`, { body: text });
153
+ }
143
154
  /** Links a created issue to the ticket it came from, the only relationship Tewip makes. */
144
155
  async link(fromKey, toKey, type = 'Relates') {
145
156
  await this.request('POST', '/issueLink', { type: { name: type }, inwardIssue: { key: fromKey }, outwardIssue: { key: toKey } });
@@ -0,0 +1,75 @@
1
+ /** One cell of a CSV, quoted the way every spreadsheet and importer expects. */
2
+ export function cell(value) {
3
+ const text = String(value ?? '').replace(/\r?\n/g, '\n');
4
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
5
+ }
6
+ const row = (cells) => cells.map(cell).join(',');
7
+ /** Xray's manual-test import: the test is repeated on every step row, as its importer expects. */
8
+ export function toXrayCsv(plan, feature) {
9
+ const header = ['Issue ID', 'Summary', 'Description', 'Test Type', 'Action', 'Data', 'Expected Result', 'Priority', 'Labels'];
10
+ const lines = [row(header)];
11
+ for (const s of plan.scenarios) {
12
+ const steps = s.steps.length > 0 ? s.steps : ['(see the expected result)'];
13
+ steps.forEach((step, index) => {
14
+ lines.push(row([
15
+ s.id,
16
+ index === 0 ? `${s.id} ${s.title}` : '',
17
+ index === 0 ? describe(s, feature) : '',
18
+ index === 0 ? 'Manual' : '',
19
+ step,
20
+ index === 0 ? s.preconditions : '',
21
+ index === steps.length - 1 ? s.expected : '',
22
+ index === 0 ? priority(s.priority) : '',
23
+ index === 0 ? `tewip ${s.type}` : '',
24
+ ]));
25
+ });
26
+ }
27
+ return lines.join('\n');
28
+ }
29
+ /** Zephyr Scale's test-case import, whose columns are named differently for the same idea. */
30
+ export function toZephyrCsv(plan, feature) {
31
+ const header = ['Name', 'Objective', 'Precondition', 'Priority', 'Status', 'Labels', 'Folder', 'Step', 'Test Data', 'Expected Result'];
32
+ const lines = [row(header)];
33
+ for (const s of plan.scenarios) {
34
+ const steps = s.steps.length > 0 ? s.steps : ['(see the expected result)'];
35
+ steps.forEach((step, index) => {
36
+ lines.push(row([
37
+ index === 0 ? `${s.id} ${s.title}` : '',
38
+ index === 0 ? describe(s, feature) : '',
39
+ index === 0 ? s.preconditions : '',
40
+ index === 0 ? priority(s.priority) : '',
41
+ index === 0 ? 'Draft' : '',
42
+ index === 0 ? `tewip,${s.type}` : '',
43
+ index === 0 ? `/${feature.replace(/[^\w -]+/g, '').slice(0, 60)}` : '',
44
+ step,
45
+ '',
46
+ index === steps.length - 1 ? s.expected : '',
47
+ ]));
48
+ });
49
+ }
50
+ return lines.join('\n');
51
+ }
52
+ const PRIORITY = { P0: 'High', P1: 'Medium', P2: 'Low' };
53
+ const priority = (p) => PRIORITY[p] ?? 'Medium';
54
+ /** What the case is for, and where it came from, so a reviewer can judge it. */
55
+ function describe(s, feature) {
56
+ return [
57
+ s.title,
58
+ `Feature: ${feature}`,
59
+ `Type: ${s.type}`,
60
+ s.grounded_on ? `Grounded on: ${s.grounded_on}` : '',
61
+ s.covered_by ? `Already covered by: ${s.covered_by}` : 'Not covered by an existing test.',
62
+ 'Drafted by Tewip; review before you rely on it.',
63
+ ]
64
+ .filter(Boolean)
65
+ .join('\n');
66
+ }
67
+ export function exportTestCases(plan, feature, format) {
68
+ return format === 'xray' ? toXrayCsv(plan, feature) : toZephyrCsv(plan, feature);
69
+ }
70
+ /** What to tell the person after writing the file, since importing is their step, not Tewip's. */
71
+ export function importHint(format, file) {
72
+ return format === 'xray'
73
+ ? `Written to ${file}. In Jira: Xray > Import > Tests from CSV, and check the column mapping on the first screen.`
74
+ : `Written to ${file}. In Jira: Zephyr Scale > Test Cases > Import > CSV, and check the column mapping on the first screen.`;
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",