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
- 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.9.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",