eslint-plugin-test-governance 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/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ All notable changes to this project are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.0] - 2026-10-30
10
+
11
+ ### Added
12
+
13
+ - Ticket presets for more trackers: `trello`, `notion`, `asana`, `clickup`, `redmine`, `monday`,
14
+ `youtrack`, `basecamp` and `shortcut`. Each accepts its tracker's ticket keys and links to one ticket,
15
+ and rejects links to boards, projects, views and other sites. `prefixes` limits the keys Notion,
16
+ ClickUp and monday.com accept, and `projects` the YouTrack projects; `host` sets a custom domain for
17
+ Notion, a server for YouTrack, and the server whose links Redmine accepts.
18
+ - A `url` preset for any other tracker: it accepts links to the `hosts` you list, and `*.example.com`
19
+ matches subdomains.
20
+ - The `azure-devops` preset accepts `#123`, which Azure Repos links to work item 123 in commit messages
21
+ and pull requests.
22
+
23
+ ### Fixed
24
+
25
+ - A link whose title ends in `-0`, as in `https://linear.app/acme/issue/ENG-1/upgrade-to-node-0`, was
26
+ reported as a placeholder ticket. Only the ticket's own ID makes it a placeholder now, which also
27
+ catches links where a title, suffix or query follows a zero ID, as in
28
+ `https://linear.app/acme/issue/ENG-0/fix-rounding`.
29
+ - `host` takes `*.example.com` to match every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`.
30
+ Before, such a host matched no link.
31
+ - `projects: []` (Jira) and `teams: []` (Linear) are config errors. An empty list rejected every key, and
32
+ the messages read "in project ,".
33
+ - Links whose host ends in a dot, as in `https://dev.azure.com./acme/web/_workitems/edit/1`, are read like
34
+ the same link without it.
35
+
9
36
  ## [0.9.0] - 2026-10-27
10
37
 
11
38
  ### Added
@@ -369,7 +396,8 @@ First release.
369
396
  - Support for ESLint 9 and 10 flat config, JavaScript and TypeScript test files, `.extend()`
370
397
  fixtures, describe blocks, runtime skips and tags in titles or the `tag` option.
371
398
 
372
- [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...HEAD
399
+ [Unreleased]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.10.0...HEAD
400
+ [0.10.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.9.0...v0.10.0
373
401
  [0.9.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.1...v0.9.0
374
402
  [0.8.1]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.8.0...v0.8.1
375
403
  [0.8.0]: https://github.com/Huldoser/eslint-plugin-test-governance/compare/v0.7.0...v0.8.0
package/README.md CHANGED
@@ -230,20 +230,20 @@ yourself. `framework` is required, and every other option has a default. The rul
230
230
  of their own: they all read these shared settings, so they always agree on ticket formats, states
231
231
  and markers. To turn a rule off, set it to `'off'` as usual.
232
232
 
233
- | Option | Type | Default | Description |
234
- | ----------------------------- | ----------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
- | `framework` | `'playwright' \| 'jest' \| 'vitest' \| 'mocha' \| 'cypress' \| 'webdriverio'` | required | The test framework of the files the config applies to. Each framework's config, such as `configs.playwright`, sets it, and `configure()` needs it. See [Frameworks](#frameworks). |
236
- | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
237
- | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
238
- | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
239
- | `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail` and `slow` are off by default; a setting for `new` or `quarantined` here wins over `lifecycleTags`. Only `new` and `quarantined` have a tag to rename; see [Tag names](#tag-names). `fixme` and `slow` exist only in Playwright, `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest, so turning one on elsewhere has no effect. |
240
- | `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. `when` is a tag and `marker` an uppercase keyword; neither can be shared with another state. |
241
- | `requireTicketForConditional` | `boolean` | `false` | Require a ticket for conditional skips too: `test.skip(condition, ...)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)`, a skip in a `catch` block, after other code in a `try` block with a `catch`, or after an early `return` or `throw`. |
242
- | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
243
- | `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: TRADE-123 flaky on CI`. Applies to markers and to `FIXME` / `TODO` comments. |
244
- | `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
245
- | `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and Vitest default to `['test', 'it']`, Mocha, Cypress and WebdriverIO to `['it', 'specify', 'test']`, and Jasmine to `['it']`. Your list replaces the default one, so include those names if you still need them. See below. |
246
- | `reportUnreadableTags` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, and tags or details that can't be read in the file, such as ones imported from another module, since their tags can't be checked. |
233
+ | Option | Type | Default | Description |
234
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
+ | `framework` | `'playwright' \| 'jest' \| 'vitest' \| 'mocha' \| 'cypress' \| 'webdriverio' \| 'node-test' \| 'jasmine'` | required | The test framework of the files the config applies to. Each framework's config, such as `configs.playwright`, sets it, and `configure()` needs it. See [Frameworks](#frameworks). |
236
+ | `ticket` | `TicketSpec \| TicketSpec[]` | `{ preset: 'any' }` | Accepted ticket formats. An array accepts a ticket that matches any entry. See [Ticket presets](#ticket-presets). |
237
+ | `placeholders` | `string[]` | see above | Tickets rejected as placeholders. `*` matches any characters. Case-insensitive; a leading `#` is ignored. Your list replaces the default one, so repeat the defaults you want to keep. Tickets numbered 0 are always rejected. |
238
+ | `lifecycleTags` | `boolean` | `false` | Turns on the `new` (`@new`) and `quarantined` (`@quarantined`) states. |
239
+ | `states` | `{ [state]: boolean \| { enabled?, tag?, marker?, ticket? } }` | `skip`, `fixme` and `todo` on | Turns built-in states (`skip`, `fixme`, `fail`, `slow`, `todo`, `new`, `quarantined`) on or off, renames their tag or marker, or gives them their own ticket format. `fail` and `slow` are off by default; a setting for `new` or `quarantined` here wins over `lifecycleTags`. Only `new` and `quarantined` have a tag to rename; see [Tag names](#tag-names). `fixme` and `slow` exist only in Playwright, `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest, so turning one on elsewhere has no effect. |
240
+ | `customStates` | `{ [name]: { when, marker, ticket? } }` | `{}` | Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. `when` is a tag and `marker` an uppercase keyword; neither can be shared with another state. |
241
+ | `requireTicketForConditional` | `boolean` | `false` | Require a ticket for conditional skips too: `test.skip(condition, ...)`, `if (...) test.skip()`, `(condition ? test.skip : test)(...)`, a skip in a `catch` block, after other code in a `try` block with a `catch`, or after an early `return` or `throw`. |
242
+ | `allowBlankLine` | `boolean` | `false` | Let blank lines separate a marker from its test. |
243
+ | `allowNotes` | `boolean` | `false` | Allow free text after the tickets, e.g. `// SKIP: TRADE-123 flaky on CI`. Applies to markers and to `FIXME` / `TODO` comments. |
244
+ | `comments` | `false \| { keywords?: string[] }` | `{ keywords: ['FIXME', 'TODO'] }` | Comment keywords that must start with a ticket anywhere in a test file, e.g. `// TODO: TRADE-123`. Your keywords replace the default ones. `false` allows free-form comments. |
245
+ | `testFunctions` | `string[]` | `['test']` | Names treated as the framework's `test`. Jest and Vitest default to `['test', 'it']`, Mocha, Cypress and WebdriverIO to `['it', 'specify', 'test']`, and Jasmine to `['it']`. Your list replaces the default one, so include those names if you still need them. See below. |
246
+ | `reportUnreadableTags` | `boolean` | `false` | When a tag state is on, report titles that aren't static text, and tags or details that can't be read in the file, such as ones imported from another module, since their tags can't be checked. |
247
247
 
248
248
  ### Tag names
249
249
 
@@ -274,21 +274,45 @@ a `customStates` entry.
274
274
 
275
275
  ### Ticket presets
276
276
 
277
- | Preset | Accepts | Options |
278
- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
279
- | `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
280
- | `jira` | `TRADE-123`, `https://<host>/browse/TRADE-123` (also under a path, as in `/jira/browse/TRADE-123`), board URLs with `selectedIssue=TRADE-123` | `projects`; `host` (any host by default) |
281
- | `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise (`github.com` by default) |
282
- | `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed (`gitlab.com` by default) |
283
- | `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...` | `teams` |
284
- | `azure-devops` | `AB#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server (`dev.azure.com` and `*.visualstudio.com` by default) |
285
- | `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
286
- | `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
287
-
288
- `projects` (Jira) and `teams` (Linear) are keys as they appear in tickets, in uppercase: `TRADE` for
289
- `TRADE-123`. Each preset takes only its own options, so `host` on the `any` preset fails with a clear
290
- message instead of being ignored. A `pattern` always matches the whole ticket, so the `g` and `y`
291
- flags make no difference and are ignored.
277
+ | Preset | Accepts | Options |
278
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
279
+ | `any` | `PROJ-123`, `#123`, `owner/repo#123`, any http(s) URL | |
280
+ | `jira` | `TRADE-123`, `https://<host>/browse/TRADE-123` (also under a path, as in `/jira/browse/TRADE-123`), board URLs with `selectedIssue=TRADE-123` | `projects`; `host` (any host by default) |
281
+ | `github` | `#123`, `owner/repo#123`, `https://github.com/owner/repo/issues/123` (or `/pull/`) | `host` for GitHub Enterprise (`github.com` by default) |
282
+ | `gitlab` | `#123`, `group/project#123`, `https://gitlab.com/group/project/-/issues/123` | `host` for self-managed (`gitlab.com` by default) |
283
+ | `linear` | `RISK-123`, `https://linear.app/<workspace>/issue/RISK-123/...` | `teams` |
284
+ | `azure-devops` | `AB#123`, `#123`, `https://dev.azure.com/<org>/<project>/_workitems/edit/123` | `host` for Azure DevOps Server (`dev.azure.com` and `*.visualstudio.com` by default) |
285
+ | `trello` | card links, `https://trello.com/c/nqPiDKmw` with or without the `/9-title` after it | |
286
+ | `notion` | Unique IDs such as `TASK-123`, `https://www.notion.so/TASK-123`, page links that end in the page ID (also `https://app.notion.com/p/...`), database links with `?p=<page ID>` | `prefixes`; `host` for a custom domain (`notion.so`, `www.notion.so`, `app.notion.com` and `*.notion.site` by default) |
287
+ | `asana` | task links, `https://app.asana.com/0/<project>/<task>` and the newer `https://app.asana.com/1/<workspace>/.../task/<task>` | |
288
+ | `clickup` | `CU-86abc123`, `#86abc123`, custom IDs such as `DEV-123`, `https://app.clickup.com/t/86abc123` or `/t/<workspace>/DEV-123`, view links with `?p=<task>` | `prefixes` for custom IDs |
289
+ | `redmine` | `#123`, `#123-4` and `#123#note-4` (a note), `##123`; with `host`, `https://<host>/issues/123` (also under a path) | `host`, needed for links |
290
+ | `monday` | item links, `https://<account>.monday.com/boards/<board>/pulses/<item>`, and custom keys such as `TIX-001` | `prefixes` for custom keys |
291
+ | `youtrack` | `KT-123`, `https://<host>/issue/KT-123/...` or `/projects/KT/issues/KT-123` (also under a path, as in `/youtrack/issue/KT-123`) | `projects`; `host` for Server or a custom domain (`youtrack.jetbrains.com`, `*.youtrack.cloud` and `*.myjetbrains.com` by default) |
292
+ | `basecamp` | to-do and card links, `https://3.basecamp.com/<account>/buckets/<project>/todos/<id>` or `/card_tables/cards/<id>` | |
293
+ | `shortcut` | `sc-123`, `[sc-123]`, `https://app.shortcut.com/<workspace>/story/123/...` | |
294
+ | `url` | links to the hosts you list, and nothing else | `hosts`, required: host names such as `tasks.example.com`, with no scheme, port or path; `*.example.com` matches its subdomains |
295
+ | `numeric` | `4821` | `minLength` (1), `maxLength` (20) |
296
+ | `pattern` | anything matching your regex, anchored at both ends | `pattern`, `flags` |
297
+
298
+ `projects` (Jira, YouTrack), `teams` (Linear) and `prefixes` (Notion, ClickUp, monday.com) are keys as
299
+ they appear in tickets, in uppercase: `TRADE` for `TRADE-123`. YouTrack project IDs may also start with a digit, as `4DM`. Without them, any key passes. `host` is a host name such as `jira.example.com`, or
300
+ `*.example.com` for every subdomain, as in `{ preset: 'jira', host: '*.atlassian.net' }`. Each preset
301
+ takes only its own options, so `host` on the `any` preset fails with a clear message instead of being
302
+ ignored. A `pattern` always matches the whole ticket, so the `g` and `y` flags make no difference and are
303
+ ignored.
304
+
305
+ A preset rejects links that aren't one ticket in its tracker, such as a Trello board, an Asana project or
306
+ a Notion database view. Redmine runs on your own server, and `/issues/123` is also GitHub's path, so its
307
+ links pass only once `host` is set.
308
+
309
+ For a tracker without a preset, use `url` with its host, for example
310
+ `{ preset: 'url', hosts: ['www.wrike.com'] }` for Wrike links such as
311
+ `https://www.wrike.com/open.htm?id=4821`. It works the same for Microsoft Planner (`tasks.office.com`),
312
+ Airtable (`airtable.com`), Smartsheet (`app.smartsheet.com`) and trackers of your own. For another
313
+ tracker's keys, such as `WEB-12`, `any` or `pattern` works. Gitea, Forgejo and Codeberg use GitHub's link layout,
314
+ so the `github` preset with their `host` covers them. Bitbucket has no issue tracker since August 2026,
315
+ so it has no preset.
292
316
 
293
317
  Presets combine, and each state can have its own format. For example, bugs in Jira and flaky-test
294
318
  reports in GitHub:
@@ -2,6 +2,8 @@
2
2
  export declare const MARKER_RE: RegExp;
3
3
  /** A tag as written in options: `@` followed by letters, digits, `-` and `_`. */
4
4
  export declare const TAG_RE: RegExp;
5
+ /** A host with no scheme, port or path: a host name, or `*.` and a domain of two or more labels for its subdomains. */
6
+ export declare const HOST_ENTRY_RE: RegExp;
5
7
  export declare const BUILTIN_STATE_NAMES: readonly ["skip", "fixme", "fail", "slow", "todo", "new", "quarantined"];
6
8
  export type BuiltinStateName = (typeof BUILTIN_STATE_NAMES)[number];
7
9
  /** The built-in states a tag puts a test in, so the only ones whose tag can be renamed. */
@@ -10,6 +12,6 @@ export type TagStateName = (typeof TAG_STATE_NAMES)[number];
10
12
  /** The test frameworks the rules understand. */
11
13
  export declare const FRAMEWORK_NAMES: readonly ["playwright", "jest", "vitest", "mocha", "cypress", "webdriverio", "node-test", "jasmine"];
12
14
  export type FrameworkName = (typeof FRAMEWORK_NAMES)[number];
13
- export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "numeric", "pattern"];
15
+ export declare const TICKET_PRESETS: readonly ["any", "jira", "github", "gitlab", "linear", "azure-devops", "trello", "notion", "asana", "clickup", "redmine", "monday", "youtrack", "basecamp", "shortcut", "url", "numeric", "pattern"];
14
16
  /** Comment keywords that must reference a ticket by default (`// TODO: TRADE-123`). */
15
17
  export declare const DEFAULT_COMMENT_KEYWORDS: string[];
@@ -2,6 +2,10 @@
2
2
  export const MARKER_RE = /^[A-Z][A-Z0-9_-]*$/;
3
3
  /** A tag as written in options: `@` followed by letters, digits, `-` and `_`. */
4
4
  export const TAG_RE = /^@[\w-]+$/;
5
+ /** One label of a host name: letters and digits, with hyphens only between them. */
6
+ const HOST_LABEL = '[A-Za-z0-9]+(?:-+[A-Za-z0-9]+)*';
7
+ /** A host with no scheme, port or path: a host name, or `*.` and a domain of two or more labels for its subdomains. */
8
+ export const HOST_ENTRY_RE = new RegExp(`^(?:${HOST_LABEL}(?:\\.${HOST_LABEL})*|\\*\\.${HOST_LABEL}(?:\\.${HOST_LABEL})+)\\.?$`);
5
9
  export const BUILTIN_STATE_NAMES = ['skip', 'fixme', 'fail', 'slow', 'todo', 'new', 'quarantined'];
6
10
  /** The built-in states a tag puts a test in, so the only ones whose tag can be renamed. */
7
11
  export const TAG_STATE_NAMES = ['new', 'quarantined'];
@@ -23,6 +27,16 @@ export const TICKET_PRESETS = [
23
27
  'gitlab',
24
28
  'linear',
25
29
  'azure-devops',
30
+ 'trello',
31
+ 'notion',
32
+ 'asana',
33
+ 'clickup',
34
+ 'redmine',
35
+ 'monday',
36
+ 'youtrack',
37
+ 'basecamp',
38
+ 'shortcut',
39
+ 'url',
26
40
  'numeric',
27
41
  'pattern',
28
42
  ];
@@ -43,8 +43,8 @@ export interface GovernanceOptions {
43
43
  * Turns built-in states on or off, renames their tag or marker, or gives them their own ticket format.
44
44
  * `skip`, `fixme` and `todo` are on by default, `fail` and `slow` off; a setting for `new` or `quarantined`
45
45
  * wins over `lifecycleTags`. Only `new` and `quarantined` have a tag, e.g.
46
- * `{ quarantined: { tag: '@flaky', marker: 'FLAKY' } }`. `fixme` and `slow` exist only in Playwright and
47
- * `todo` only in Jest and Vitest.
46
+ * `{ quarantined: { tag: '@flaky', marker: 'FLAKY' } }`. `fixme` and `slow` exist only in Playwright,
47
+ * `todo` in every framework but Playwright, and `fail` in Playwright, Jest and Vitest.
48
48
  */
49
49
  states?: Partial<Record<BuiltinStateName, boolean | StateOverride>>;
50
50
  /** Adds a state for a tag, e.g. `{ 'needs-data': { when: '@needs-data', marker: 'NEEDS-DATA' } }`. */
@@ -1,4 +1,4 @@
1
- import { BUILTIN_STATE_NAMES, DEFAULT_COMMENT_KEYWORDS, } from "./constants.js";
1
+ import { BUILTIN_STATE_NAMES, DEFAULT_COMMENT_KEYWORDS, HOST_ENTRY_RE, } from "./constants.js";
2
2
  import { ConfigError } from "./errors.js";
3
3
  import { FRAMEWORKS } from "./frameworks.js";
4
4
  import { optionsSchema } from "./schema.js";
@@ -46,9 +46,43 @@ const PRESET_OPTIONS = {
46
46
  gitlab: ['host'],
47
47
  linear: ['teams'],
48
48
  'azure-devops': ['host'],
49
+ trello: [],
50
+ notion: ['prefixes', 'host'],
51
+ asana: [],
52
+ clickup: ['prefixes'],
53
+ redmine: ['host'],
54
+ monday: ['prefixes'],
55
+ youtrack: ['projects', 'host'],
56
+ basecamp: [],
57
+ shortcut: [],
58
+ url: ['hosts'],
49
59
  numeric: ['minLength', 'maxLength'],
50
60
  pattern: ['pattern', 'flags'],
51
61
  };
62
+ /**
63
+ * Presets whose `host` must be a plain host name. The older presets still take any string, since
64
+ * rejecting one now would break configs whose short forms, such as `#123`, already work.
65
+ */
66
+ const STRICT_HOST_PRESETS = new Set(['notion', 'redmine', 'youtrack']);
67
+ /** Option values the schema allows for every preset but this one can't use. */
68
+ function ownOptionProblems(spec, at) {
69
+ const problems = [];
70
+ if (spec.preset === 'jira') {
71
+ for (const [i, key] of (spec.projects ?? []).entries()) {
72
+ if (!/^[A-Z][A-Z0-9_]+$/.test(key)) {
73
+ problems.push(`${at}.projects[${i}] must start with a letter and have at least two characters, as Jira keys do (got "${key}")`);
74
+ }
75
+ }
76
+ }
77
+ const { host } = spec;
78
+ if (STRICT_HOST_PRESETS.has(spec.preset) && host !== undefined && !HOST_ENTRY_RE.test(host)) {
79
+ problems.push(`${at}.host must be a host name such as tracker.example.com or *.example.com, with no scheme, port or path (got "${host}")`);
80
+ }
81
+ if (spec.preset === 'url' && spec.hosts === undefined) {
82
+ problems.push(`${at}.hosts is required by the "url" preset: the hosts its links may point to`);
83
+ }
84
+ return problems;
85
+ }
52
86
  /** Options set on a ticket spec that its preset ignores, such as `host` on the `any` preset. */
53
87
  function presetProblems(ticket, path) {
54
88
  if (ticket === undefined)
@@ -57,12 +91,13 @@ function presetProblems(ticket, path) {
57
91
  return specs.flatMap((spec, index) => {
58
92
  const at = Array.isArray(ticket) ? `${path}[${index}]` : path;
59
93
  const allowed = PRESET_OPTIONS[spec.preset];
60
- return Object.keys(spec)
94
+ const misplaced = Object.keys(spec)
61
95
  .filter((key) => key !== 'preset' && spec[key] !== undefined && !allowed.includes(key))
62
96
  .map((key) => {
63
97
  const presets = Object.entries(PRESET_OPTIONS).filter(([, keys]) => keys.includes(key));
64
98
  return `${at}.${key} is not an option of the "${spec.preset}" preset (it applies to ${presets.map(([name]) => `"${name}"`).join(', ')})`;
65
99
  });
100
+ return [...misplaced, ...ownOptionProblems(spec, at)];
66
101
  });
67
102
  }
68
103
  /** Problems the schema can't express: options that belong to another preset, and reused state names. */
@@ -1,9 +1,13 @@
1
- import { BUILTIN_STATE_NAMES, FRAMEWORK_NAMES, MARKER_RE, TAG_RE, TAG_STATE_NAMES, TICKET_PRESETS, } from "./constants.js";
1
+ import { BUILTIN_STATE_NAMES, FRAMEWORK_NAMES, HOST_ENTRY_RE, MARKER_RE, TAG_RE, TAG_STATE_NAMES, TICKET_PRESETS, } from "./constants.js";
2
2
  const MARKER_HINT = 'an uppercase keyword such as NEEDS-DATA (letters, digits, "-" and "_")';
3
3
  const TAG_HINT = 'a tag such as @needs-data';
4
4
  /** Jira project and Linear team keys, as they appear in tickets: `WEB` in `WEB-123`. */
5
5
  const KEY_PATTERN = '^[A-Z][A-Z0-9_]+$';
6
6
  const KEY_HINT = 'an uppercase key as it appears in tickets, such as WEB for WEB-123';
7
+ /** Linear teams and the prefixes of Notion, ClickUp and monday.com keys have at least two characters. */
8
+ const LONG_KEY_HINT = 'an uppercase key of two or more characters as it appears in tickets, such as WEB for WEB-123';
9
+ /** YouTrack project IDs may also start with a digit, as `4DM` in `4DM-12`; `jira` checks its own in options.ts. */
10
+ const PROJECT_PATTERN = '^(?=[A-Z0-9_]*[A-Z])[A-Z0-9_]+$';
7
11
  /** `['"a"', '"b"', '"c"']` as `"a", "b" or "c"`. */
8
12
  const orList = (items) => `${items.slice(0, -1).join(', ')} or ${items.slice(-1).join('')}`;
9
13
  /** How code reads a framework's config: `configs.mocha`, or `configs['node-test']` for a name with a dash. */
@@ -13,9 +17,27 @@ const ticketSpecSchema = {
13
17
  type: 'object',
14
18
  properties: {
15
19
  preset: { type: 'string', enum: [...TICKET_PRESETS] },
16
- projects: { type: 'array', items: { type: 'string', pattern: KEY_PATTERN, patternHint: KEY_HINT } },
17
- teams: { type: 'array', items: { type: 'string', pattern: KEY_PATTERN, patternHint: KEY_HINT } },
20
+ projects: {
21
+ type: 'array',
22
+ items: { type: 'string', pattern: PROJECT_PATTERN, patternHint: KEY_HINT },
23
+ minItems: 1,
24
+ },
25
+ teams: { type: 'array', items: { type: 'string', pattern: KEY_PATTERN, patternHint: LONG_KEY_HINT }, minItems: 1 },
26
+ prefixes: {
27
+ type: 'array',
28
+ items: { type: 'string', pattern: KEY_PATTERN, patternHint: LONG_KEY_HINT },
29
+ minItems: 1,
30
+ },
18
31
  host: { type: 'string' },
32
+ hosts: {
33
+ type: 'array',
34
+ items: {
35
+ type: 'string',
36
+ pattern: HOST_ENTRY_RE.source,
37
+ patternHint: 'a host name such as tasks.example.com or *.example.com',
38
+ },
39
+ minItems: 1,
40
+ },
19
41
  minLength: { type: 'integer', minimum: 1 },
20
42
  maxLength: { type: 'integer', minimum: 1 },
21
43
  pattern: { type: 'string' },
@@ -16,6 +16,34 @@ export type TicketSpec = {
16
16
  } | {
17
17
  preset: 'azure-devops';
18
18
  host?: string;
19
+ } | {
20
+ preset: 'trello';
21
+ } | {
22
+ preset: 'notion';
23
+ prefixes?: string[];
24
+ host?: string;
25
+ } | {
26
+ preset: 'asana';
27
+ } | {
28
+ preset: 'clickup';
29
+ prefixes?: string[];
30
+ } | {
31
+ preset: 'redmine';
32
+ host?: string;
33
+ } | {
34
+ preset: 'monday';
35
+ prefixes?: string[];
36
+ } | {
37
+ preset: 'youtrack';
38
+ projects?: string[];
39
+ host?: string;
40
+ } | {
41
+ preset: 'basecamp';
42
+ } | {
43
+ preset: 'shortcut';
44
+ } | {
45
+ preset: 'url';
46
+ hosts: string[];
19
47
  } | {
20
48
  preset: 'numeric';
21
49
  minLength?: number;
@@ -10,12 +10,39 @@ const AZURE_REF_RE = /^AB#\d+$/;
10
10
  const DIGITS_RE = /^\d+$/;
11
11
  // Jira Server and Data Center can run under a path, as in https://issues.example.com/jira/browse/TRADE-1.
12
12
  const JIRA_PATH_RE = /\/browse\/([A-Z][A-Z0-9_]+-\d+)\/?$/;
13
- const GITHUB_PATH_RE = /^\/[^/]+\/[^/]+\/(?:issues|pull)\/\d+\/?$/;
14
- const GITLAB_PATH_RE = /\/-\/(?:issues|merge_requests|work_items)\/\d+\/?$/;
13
+ const GITHUB_PATH_RE = /^\/[^/]+\/[^/]+\/(?:issues|pull)\/(\d+)\/?$/;
14
+ const GITLAB_PATH_RE = /\/-\/(?:issues|merge_requests|work_items)\/(\d+)\/?$/;
15
15
  const LINEAR_PATH_RE = /^\/[^/]+\/issue\/([A-Z][A-Z0-9]*-\d+)(?:\/[^/]*)?$/;
16
- const AZURE_PATH_RE = /\/_workitems\/edit\/\d+\/?$/;
17
- // No tracker issues number 0, so PROJ-0, #0, AB#0, .../issues/0 and 000 are always placeholders.
18
- const ZERO_ID_RE = /(?:^|[-#/])0+\/?$/;
16
+ const AZURE_PATH_RE = /\/_workitems\/edit\/(\d+)\/?$/;
17
+ const TRELLO_PATH_RE = /^\/c\/([A-Za-z0-9]{8})(?:\/[^/]*)?$/;
18
+ // A Notion page ID is 32 hex digits, plain or dashed as a UUID, at the end of the page's last path segment.
19
+ const NOTION_ID = '[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
20
+ const NOTION_PAGE_RE = new RegExp(`(?:^|-)(${NOTION_ID})$`);
21
+ const NOTION_ID_RE = new RegExp(`^(?:${NOTION_ID})$`);
22
+ const ASANA_PATH_RE = /^\/(?:0\/\d+\/(\d+)(?:\/f)?|1\/\d+(?:\/project\/\d+|\/home)?\/task\/(\d+)(?:\/comment\/\d+)?)\/?$/;
23
+ // ClickUp task IDs are lowercase letters and digits, with at least one digit.
24
+ const CLICKUP_ID_RE = /^(?=[a-z]*\d)[a-z0-9]+$/;
25
+ const CLICKUP_REF_RE = /^(?:CU-|#)([a-z0-9]+)$/;
26
+ const CLICKUP_TASK_PATH_RE = /^\/t\/(?:\d+\/)?([^/]+)\/?$/;
27
+ const CLICKUP_VIEW_PATH_RE = /^\/\d+\/v\//;
28
+ // `#123`, a note of it (`#123-4`, `#123#note-4`), and `##123`, which Redmine shows with its subject.
29
+ const REDMINE_REF_RE = /^##?(\d+)(?:-\d+|#note-\d+)?$/;
30
+ const REDMINE_PATH_RE = /\/issues\/(\d+)(?:\.(?:json|xml|pdf|atom))?\/?$/;
31
+ const MONDAY_PATH_RE = /^\/boards\/\d+\/pulses\/(\d+)(?:\/.*)?$/;
32
+ // YouTrack project IDs may start with a digit (4DM-12) but can't be digits only.
33
+ const YOUTRACK_KEY_RE = /^([A-Z0-9_]+)-\d+$/;
34
+ // `/issue/KT-1` and the newer `/projects/KT/issues/KT-1`, with an optional title after the key.
35
+ const YOUTRACK_PATH_RE = /\/(?:issue|projects\/[^/]+\/issues)\/([A-Z0-9_]+-\d+)(?:\/[^/]*)?$/;
36
+ const BASECAMP_PATH_RE = /^\/\d+\/buckets\/\d+\/(?:todos|card_tables\/cards)\/(\d+)\/?$/;
37
+ const SHORTCUT_REF_RE = /^(?:sc-(\d+)|\[sc-(\d+)\])$/i;
38
+ const SHORTCUT_PATH_RE = /^\/[^/]+\/story\/(\d+)(?:\/[^/]*)?$/;
39
+ // No tracker issues number 0. A preset hands back the ID it finds, and an ID of zeros (0, 000, or a
40
+ // Notion page ID of zeros) makes the ticket a placeholder. A ticket no preset reads is a placeholder
41
+ // when it ends in a zero ID, as PROJ-0, #0, AB#0 or .../issues/0 do.
42
+ const ZERO_RE = /^(?:0+|0{8}-0{4}-0{4}-0{4}-0{12})$/;
43
+ const ZERO_ID_RE = /(?:^|[-#/])0+[/\]]?$/;
44
+ // The number at the end of a link to an unknown tracker, as in `/issues/123`.
45
+ const LINK_NUMBER_RE = /\/(\d+)\/?$/;
19
46
  function parseUrl(ticket) {
20
47
  if (!/^https?:\/\//.test(ticket))
21
48
  return undefined;
@@ -26,48 +53,101 @@ function parseUrl(ticket) {
26
53
  return undefined;
27
54
  }
28
55
  }
56
+ /** The link's host name in lowercase, without the trailing dot that `trello.com.` may have. */
57
+ function hostnameOf(url) {
58
+ return url.hostname.toLowerCase().replace(/\.$/, '');
59
+ }
60
+ /** `*.example.com` matches the subdomains of example.com; any other host matches exactly. */
29
61
  function hostMatches(url, host) {
30
- return url.hostname.toLowerCase() === host.toLowerCase();
62
+ const hostname = hostnameOf(url);
63
+ const lower = host.toLowerCase().replace(/\.$/, '');
64
+ return lower.startsWith('*.') ? hostname.endsWith(lower.slice(1)) : hostname === lower;
65
+ }
66
+ function hostInList(url, hosts) {
67
+ return hosts.some((host) => hostMatches(url, host));
68
+ }
69
+ function isYouTrackKey(key, projects) {
70
+ const project = YOUTRACK_KEY_RE.exec(key)?.[1];
71
+ return project !== undefined && /[A-Z]/.test(project) && inList(project, projects);
31
72
  }
32
73
  function inList(value, list) {
33
74
  return list === undefined || list.includes(value);
34
75
  }
35
- function describeList(noun, list) {
36
- return list === undefined ? '' : ` in ${noun} ${list.join(', ')}`;
76
+ function keyInList(key, list) {
77
+ return inList(key.slice(0, key.lastIndexOf('-')), list);
78
+ }
79
+ /** `TRADE-1` for `TRADE-1/`, as a query value copied with the link's trailing slash reads. */
80
+ function trimSlash(value) {
81
+ return value.replace(/\/$/, '');
82
+ }
83
+ /** The number in `TRADE-123`, `#123`, `owner/repo#123` or `AB#123`. */
84
+ function numberOf(ticket) {
85
+ return ticket.slice(Math.max(ticket.lastIndexOf('-'), ticket.lastIndexOf('#')) + 1);
86
+ }
87
+ /** `TRADE-123`'s number, when the key passes `ok`. */
88
+ function keyId(key, ok) {
89
+ return key !== null && key !== undefined && ok(key) ? numberOf(key) : undefined;
90
+ }
91
+ /**
92
+ * A link's ID when the preset doesn't know the tracker: the keys in its path, `#/route` or query (Jira's
93
+ * `?selectedIssue=TRADE-1`) and the number its path or route ends in. A zero among them makes the link a
94
+ * placeholder, as in 0.9.0; with none, the link itself stands for the ID.
95
+ */
96
+ function linkId(url) {
97
+ const route = /^#!?\//.test(url.hash) ? url.hash.replace(/^#!?/, '') : '';
98
+ const parts = [...url.pathname.split('/'), ...route.split('/'), ...[...url.searchParams.values()].map(trimSlash)];
99
+ const keys = parts.filter((part) => KEY_RE.test(part));
100
+ const ends = [url.pathname, route].flatMap((path) => LINK_NUMBER_RE.exec(path)?.[1] ?? []);
101
+ const numbers = [...keys, ...ends].map(numberOf);
102
+ return numbers.find((id) => ZERO_RE.test(id)) ?? numbers.at(0) ?? url.href;
103
+ }
104
+ /** `TRADE, RISK or OPS` for `['TRADE', 'RISK', 'OPS']`. */
105
+ function orList(list) {
106
+ return list.length === 1 ? list[0] : `${list.slice(0, -1).join(', ')} or ${list[list.length - 1]}`;
107
+ }
108
+ /** ` in project TRADE or RISK` for `describeList('in project', ['TRADE', 'RISK'])`. */
109
+ function describeList(words, list) {
110
+ return list === undefined ? '' : ` ${words} ${orList(list)}`;
37
111
  }
38
112
  function compilePreset(spec) {
39
113
  switch (spec.preset) {
40
114
  case 'any':
41
115
  return {
42
- test: (t) => KEY_RE.test(t) || ISSUE_NUMBER_RE.test(t) || GITLAB_REF_RE.test(t) || parseUrl(t) !== undefined,
116
+ find: (t) => {
117
+ if (KEY_RE.test(t) || ISSUE_NUMBER_RE.test(t) || GITLAB_REF_RE.test(t))
118
+ return numberOf(t);
119
+ const url = parseUrl(t);
120
+ return url && linkId(url);
121
+ },
43
122
  expected: 'a ticket key like PROJ-123, an issue like #4821 or owner/repo#4821, or a URL',
44
123
  example: 'PROJ-123',
124
+ generic: true,
45
125
  };
46
126
  case 'jira': {
47
127
  const { projects, host } = spec;
48
- const keyOk = (key) => inList(key.slice(0, key.lastIndexOf('-')), projects);
128
+ const keyOk = (key) => KEY_RE.test(key) && keyInList(key, projects);
49
129
  return {
50
- test: (t) => {
130
+ find: (t) => {
51
131
  if (KEY_RE.test(t))
52
- return keyOk(t);
132
+ return keyId(t, keyOk);
53
133
  const url = parseUrl(t);
54
134
  if (!url || (host !== undefined && !hostMatches(url, host)))
55
- return false;
56
- const key = JIRA_PATH_RE.exec(url.pathname)?.[1] ?? url.searchParams.get('selectedIssue');
57
- return key !== null && KEY_RE.test(key) && keyOk(key);
135
+ return undefined;
136
+ const selected = url.searchParams.get('selectedIssue');
137
+ return keyId(JIRA_PATH_RE.exec(url.pathname)?.[1] ?? (selected === null ? null : trimSlash(selected)), keyOk);
58
138
  },
59
- expected: `a Jira key like ${projects?.[0] ?? 'PROJ'}-123${describeList('project', projects)}, or a Jira URL`,
139
+ expected: `a Jira key like ${projects?.[0] ?? 'PROJ'}-123${describeList('in project', projects)}, or a Jira URL${host === undefined ? '' : ` on ${host}`}`,
60
140
  example: `${projects?.[0] ?? 'PROJ'}-123`,
61
141
  };
62
142
  }
63
143
  case 'github': {
64
144
  const host = spec.host ?? 'github.com';
65
145
  return {
66
- test: (t) => {
146
+ find: (t) => {
67
147
  if (ISSUE_NUMBER_RE.test(t) || GITHUB_REF_RE.test(t))
68
- return true;
148
+ return numberOf(t);
69
149
  const url = parseUrl(t);
70
- return url !== undefined && hostMatches(url, host) && GITHUB_PATH_RE.test(url.pathname);
150
+ return url && hostMatches(url, host) ? GITHUB_PATH_RE.exec(url.pathname)?.[1] : undefined;
71
151
  },
72
152
  expected: `a GitHub issue like #4821 or owner/repo#4821, or an issue URL on ${host}`,
73
153
  example: '#4821',
@@ -76,11 +156,11 @@ function compilePreset(spec) {
76
156
  case 'gitlab': {
77
157
  const host = spec.host ?? 'gitlab.com';
78
158
  return {
79
- test: (t) => {
159
+ find: (t) => {
80
160
  if (ISSUE_NUMBER_RE.test(t) || GITLAB_REF_RE.test(t))
81
- return true;
161
+ return numberOf(t);
82
162
  const url = parseUrl(t);
83
- return url !== undefined && hostMatches(url, host) && GITLAB_PATH_RE.test(url.pathname);
163
+ return url && hostMatches(url, host) ? GITLAB_PATH_RE.exec(url.pathname)?.[1] : undefined;
84
164
  },
85
165
  expected: `a GitLab issue like #4821 or group/project#4821, or an issue URL on ${host}`,
86
166
  example: '#4821',
@@ -88,39 +168,193 @@ function compilePreset(spec) {
88
168
  }
89
169
  case 'linear': {
90
170
  const { teams } = spec;
91
- const keyOk = (key) => inList(key.slice(0, key.lastIndexOf('-')), teams);
171
+ const keyOk = (key) => keyInList(key, teams);
92
172
  return {
93
- test: (t) => {
173
+ find: (t) => {
94
174
  if (KEY_RE.test(t))
95
- return keyOk(t);
175
+ return keyId(t, keyOk);
96
176
  const url = parseUrl(t);
97
- if (!url || !hostMatches(url, 'linear.app'))
98
- return false;
99
- const key = LINEAR_PATH_RE.exec(url.pathname)?.[1];
100
- return key !== undefined && keyOk(key);
177
+ return url && hostMatches(url, 'linear.app')
178
+ ? keyId(LINEAR_PATH_RE.exec(url.pathname)?.[1], keyOk)
179
+ : undefined;
101
180
  },
102
- expected: `a Linear issue like ${teams?.[0] ?? 'ENG'}-123${describeList('team', teams)}, or a Linear URL`,
181
+ expected: `a Linear issue like ${teams?.[0] ?? 'ENG'}-123${describeList('in team', teams)}, or a Linear URL`,
103
182
  example: `${teams?.[0] ?? 'ENG'}-123`,
104
183
  };
105
184
  }
106
185
  case 'azure-devops': {
107
186
  const { host } = spec;
108
187
  return {
109
- test: (t) => {
110
- if (AZURE_REF_RE.test(t))
111
- return true;
188
+ find: (t) => {
189
+ if (AZURE_REF_RE.test(t) || ISSUE_NUMBER_RE.test(t))
190
+ return numberOf(t);
112
191
  const url = parseUrl(t);
113
192
  if (!url)
114
- return false;
193
+ return undefined;
115
194
  const hostOk = host === undefined
116
- ? hostMatches(url, 'dev.azure.com') || url.hostname.toLowerCase().endsWith('.visualstudio.com')
195
+ ? hostMatches(url, 'dev.azure.com') || hostnameOf(url).endsWith('.visualstudio.com')
117
196
  : hostMatches(url, host);
118
- return hostOk && AZURE_PATH_RE.test(url.pathname);
197
+ return hostOk ? AZURE_PATH_RE.exec(url.pathname)?.[1] : undefined;
119
198
  },
120
- expected: 'an Azure Boards work item like AB#4821, or a work item URL',
199
+ expected: `an Azure Boards work item like AB#4821 or #4821, or a work item URL${host === undefined ? '' : ` on ${host}`}`,
121
200
  example: 'AB#4821',
122
201
  };
123
202
  }
203
+ case 'trello':
204
+ return {
205
+ find: (t) => {
206
+ const url = parseUrl(t);
207
+ return url && hostMatches(url, 'trello.com') ? TRELLO_PATH_RE.exec(url.pathname)?.[1] : undefined;
208
+ },
209
+ expected: 'a Trello card link like https://trello.com/c/nqPiDKmw',
210
+ example: 'https://trello.com/c/nqPiDKmw',
211
+ };
212
+ case 'notion': {
213
+ const { prefixes, host } = spec;
214
+ const hosts = host === undefined ? ['notion.so', 'www.notion.so', 'app.notion.com', '*.notion.site'] : [host];
215
+ const keyOk = (key) => keyInList(key, prefixes);
216
+ return {
217
+ find: (t) => {
218
+ if (KEY_RE.test(t))
219
+ return keyId(t, keyOk);
220
+ const url = parseUrl(t);
221
+ if (!url || !hostInList(url, hosts))
222
+ return undefined;
223
+ const peek = url.searchParams.get('p');
224
+ if (peek !== null)
225
+ return NOTION_ID_RE.test(peek) ? peek : undefined;
226
+ if (url.searchParams.has('v'))
227
+ return undefined;
228
+ const segment = /^\/(?:[^/]+\/)?([^/]+)$/.exec(url.pathname)?.[1];
229
+ if (segment === undefined)
230
+ return undefined;
231
+ return KEY_RE.test(segment) ? keyId(segment, keyOk) : NOTION_PAGE_RE.exec(segment)?.[1];
232
+ },
233
+ expected: `a Notion ID like ${prefixes?.[0] ?? 'TASK'}-123${describeList('with prefix', prefixes)}, or a Notion page link${host === undefined ? '' : ` on ${host}`}`,
234
+ example: `${prefixes?.[0] ?? 'TASK'}-123`,
235
+ };
236
+ }
237
+ case 'asana':
238
+ return {
239
+ find: (t) => {
240
+ const url = parseUrl(t);
241
+ const match = url && hostMatches(url, 'app.asana.com') ? ASANA_PATH_RE.exec(url.pathname) : undefined;
242
+ // The task ID is in the first group for the /0/ layout and the second for /1/.
243
+ return match?.slice(1).find(Boolean);
244
+ },
245
+ expected: 'an Asana task link like https://app.asana.com/0/0/1204821736251904',
246
+ example: 'https://app.asana.com/0/0/1204821736251904',
247
+ };
248
+ case 'clickup': {
249
+ const { prefixes } = spec;
250
+ const keyOk = (key) => KEY_RE.test(key) && keyInList(key, prefixes);
251
+ const taskId = (id) => id !== null && id !== undefined && CLICKUP_ID_RE.test(id) ? id : keyId(id, keyOk);
252
+ return {
253
+ find: (t) => {
254
+ const ref = CLICKUP_REF_RE.exec(t)?.[1];
255
+ if (ref !== undefined)
256
+ return taskId(ref);
257
+ if (KEY_RE.test(t))
258
+ return keyId(t, keyOk);
259
+ const url = parseUrl(t);
260
+ if (!url || !hostMatches(url, 'app.clickup.com'))
261
+ return undefined;
262
+ if (CLICKUP_VIEW_PATH_RE.test(url.pathname)) {
263
+ const peek = url.searchParams.get('p');
264
+ return peek !== null && CLICKUP_ID_RE.test(peek) ? peek : undefined;
265
+ }
266
+ return taskId(CLICKUP_TASK_PATH_RE.exec(url.pathname)?.[1]);
267
+ },
268
+ expected: `a ClickUp task like CU-86abc123 or ${prefixes?.[0] ?? 'DEV'}-123${describeList('with prefix', prefixes)}, or a ClickUp task link`,
269
+ example: prefixes === undefined ? 'CU-86abc123' : `${prefixes[0]}-123`,
270
+ };
271
+ }
272
+ case 'redmine': {
273
+ const { host } = spec;
274
+ return {
275
+ find: (t) => {
276
+ const ref = REDMINE_REF_RE.exec(t)?.[1];
277
+ if (ref !== undefined)
278
+ return ref;
279
+ const url = parseUrl(t);
280
+ return url && host !== undefined && hostMatches(url, host)
281
+ ? REDMINE_PATH_RE.exec(url.pathname)?.[1]
282
+ : undefined;
283
+ },
284
+ expected: host === undefined
285
+ ? 'a Redmine issue like #4821 (set host to accept issue links)'
286
+ : `a Redmine issue like #4821, or an issue URL on ${host}`,
287
+ example: '#4821',
288
+ };
289
+ }
290
+ case 'monday': {
291
+ const { prefixes } = spec;
292
+ const keyOk = (key) => keyInList(key, prefixes);
293
+ return {
294
+ find: (t) => {
295
+ if (KEY_RE.test(t))
296
+ return keyId(t, keyOk);
297
+ const url = parseUrl(t);
298
+ return url && hostInList(url, ['*.monday.com']) ? MONDAY_PATH_RE.exec(url.pathname)?.[1] : undefined;
299
+ },
300
+ expected: `a monday.com item link like https://acme.monday.com/boards/1234567890/pulses/1234567891, or a custom key like ${prefixes?.[0] ?? 'TIX'}-001${describeList('with prefix', prefixes)}`,
301
+ example: 'https://acme.monday.com/boards/1234567890/pulses/1234567891',
302
+ };
303
+ }
304
+ case 'youtrack': {
305
+ const { projects, host } = spec;
306
+ const hosts = host === undefined ? ['youtrack.jetbrains.com', '*.youtrack.cloud', '*.myjetbrains.com'] : [host];
307
+ const keyOk = (key) => isYouTrackKey(key, projects);
308
+ return {
309
+ find: (t) => {
310
+ if (YOUTRACK_KEY_RE.test(t))
311
+ return keyId(t, keyOk);
312
+ const url = parseUrl(t);
313
+ return url && hostInList(url, hosts) ? keyId(YOUTRACK_PATH_RE.exec(url.pathname)?.[1], keyOk) : undefined;
314
+ },
315
+ expected: `a YouTrack issue like ${projects?.[0] ?? 'PROJ'}-123${describeList('in project', projects)}, or a YouTrack issue link${host === undefined ? '' : ` on ${host}`}`,
316
+ example: `${projects?.[0] ?? 'PROJ'}-123`,
317
+ };
318
+ }
319
+ case 'basecamp':
320
+ return {
321
+ find: (t) => {
322
+ const url = parseUrl(t);
323
+ return url && hostMatches(url, '3.basecamp.com') ? BASECAMP_PATH_RE.exec(url.pathname)?.[1] : undefined;
324
+ },
325
+ expected: 'a Basecamp to-do or card link like https://3.basecamp.com/4821/buckets/4822/todos/4823',
326
+ example: 'https://3.basecamp.com/4821/buckets/4822/todos/4823',
327
+ };
328
+ case 'shortcut':
329
+ return {
330
+ find: (t) => {
331
+ // `sc-123` fills the first group and `[sc-123]` the second.
332
+ const ref = SHORTCUT_REF_RE.exec(t)?.slice(1).find(Boolean);
333
+ if (ref !== undefined)
334
+ return ref;
335
+ const url = parseUrl(t);
336
+ return url && hostInList(url, ['app.shortcut.com', 'app.clubhouse.io'])
337
+ ? SHORTCUT_PATH_RE.exec(url.pathname)?.[1]
338
+ : undefined;
339
+ },
340
+ expected: 'a Shortcut story like sc-123, or a story link',
341
+ example: 'sc-123',
342
+ };
343
+ case 'url': {
344
+ const { hosts } = spec;
345
+ return {
346
+ find: (t) => {
347
+ const url = parseUrl(t);
348
+ if (!url || !hostInList(url, hosts))
349
+ return undefined;
350
+ // The site's home page is no ticket; a query or a `#/route` after it can be.
351
+ return url.pathname === '/' && url.search === '' && url.hash === '' ? undefined : linkId(url);
352
+ },
353
+ expected: `a link to ${orList(hosts)}`,
354
+ generic: true,
355
+ example: `https://${hosts[0].replace(/^\*\./, 'tasks.')}/4821`,
356
+ };
357
+ }
124
358
  case 'numeric': {
125
359
  const min = spec.minLength ?? 1;
126
360
  const max = spec.maxLength ?? 20;
@@ -128,7 +362,7 @@ function compilePreset(spec) {
128
362
  throw new ConfigError(`numeric ticket minLength (${min}) is greater than maxLength (${max}).`);
129
363
  }
130
364
  return {
131
- test: (t) => DIGITS_RE.test(t) && t.length >= min && t.length <= max,
365
+ find: (t) => (DIGITS_RE.test(t) && t.length >= min && t.length <= max ? t : undefined),
132
366
  expected: min === max ? `a ${min}-digit number` : `a number with ${min} to ${max} digits`,
133
367
  example: '482173'.repeat(4).slice(0, Math.min(Math.max(4, min), max)),
134
368
  };
@@ -147,7 +381,13 @@ function compilePreset(spec) {
147
381
  catch (error) {
148
382
  throw new ConfigError(`invalid ticket pattern "${spec.pattern}": ${error.message}`);
149
383
  }
150
- return { test: (t) => re.test(t), expected: `a ticket matching /${spec.pattern}/`, example: 'TICKET' };
384
+ // The regex is the user's, so the ID is the whole ticket; one that ends in a zero ID is a placeholder.
385
+ return {
386
+ find: (t) => (re.test(t) ? (ZERO_ID_RE.test(t) ? '0' : t) : undefined),
387
+ expected: `a ticket matching /${spec.pattern}/`,
388
+ example: 'TICKET',
389
+ generic: true,
390
+ };
151
391
  }
152
392
  }
153
393
  }
@@ -164,9 +404,16 @@ export function compileTicketSpec(specs, placeholders) {
164
404
  return {
165
405
  check(ticket) {
166
406
  const bare = ticket.replace(/^#/, '');
167
- if (ZERO_ID_RE.test(ticket) || placeholderRes.some((re) => re.test(ticket) || re.test(bare)))
407
+ if (placeholderRes.some((re) => re.test(ticket) || re.test(bare)))
168
408
  return 'placeholder';
169
- return presets.some((preset) => preset.test(ticket)) ? 'ok' : 'format';
409
+ for (const generic of [false, true]) {
410
+ const ids = presets.flatMap((preset) => Boolean(preset.generic) === generic ? (preset.find(ticket) ?? []) : []);
411
+ if (ids.length > 0)
412
+ return ids.some((id) => !ZERO_RE.test(id)) ? 'ok' : 'placeholder';
413
+ }
414
+ // No preset reads it. A link is a placeholder only when its own ID is 0, not its title.
415
+ const url = parseUrl(ticket);
416
+ return (url ? ZERO_RE.test(linkId(url)) : ZERO_ID_RE.test(ticket)) ? 'placeholder' : 'format';
170
417
  },
171
418
  expected: presets.map((p) => p.expected).join('; or '),
172
419
  example: presets[0].example,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-test-governance",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "ESLint rules that require a ticket comment above skipped, fixme, todo and tagged Playwright, Jest, Vitest, Mocha, Cypress, WebdriverIO, node:test and Jasmine tests.",
5
5
  "keywords": [
6
6
  "eslint",