tewip-ai 0.9.0 → 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/TEAM-GUIDE.md
CHANGED
|
@@ -140,6 +140,8 @@ Notes that save you time:
|
|
|
140
140
|
- `write` never touches your application code. It only adds new test files, and you review and
|
|
141
141
|
commit them yourself.
|
|
142
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.
|
|
143
145
|
- Jira works with **Cloud, Server and Data Center**. To read tickets from a tracker anyone can read
|
|
144
146
|
(many open-source ones), `TEWIP_JIRA_URL` alone is enough - no account. Writing needs a token.
|
|
145
147
|
- `tewip jira` shows what it *would* file and creates nothing until you add `--create`. It never
|
|
@@ -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",
|