From 1478a25d509a23008c7374bdaf056c20e0ecbb82 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:50:51 -0400 Subject: [PATCH 1/3] feat(work-items): surface won't-do blockers in triage and bind Jira resolutions The triage attention view gains a fourth bucket, "blocked by won't-do", listing open items whose blocked_by_wont_do_count is above zero whatever labels they carry; the triage board accepts it as a state. The Jira adapter now reads done inward blockers' resolutions in one POST /search/jql (fields=resolution) per call or page and counts a blocker resolved as one of config.jira.wont_do_resolutions (team binding key, default ["Won't Do","Duplicate"], [] turns it off) in blocked_by_count and blocked_by_wont_do_count. A failed lookup keeps the done blocker blocking, matching the GitHub adapter; blockers outside project_keys are not looked up and stay resolved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AXBazvq2HJYYKarzkTHrng --- ...t-work-items-wont-do-followups-79e9c670.md | 7 ++ .../skills/setup/reference/providers.md | 2 +- plugins/work-items/skills/triage/SKILL.md | 7 +- .../work-items/skills/triage/context/board.md | 3 +- .../work-items/skills/triage/evals/evals.json | 16 +++- .../skills/triage/scripts/build-board.mjs | 3 +- plugins/work-items/tests/triage-board.test.sh | 5 + .../tools/work-item-tracker/CONTRACT.md | 31 +++++-- .../work-item-tracker/adapters/jira/README.md | 14 +-- .../work-item-tracker/adapters/jira/common.sh | 92 ++++++++++++++++--- .../adapters/jira/get-item.sh | 5 + .../adapters/jira/get-item.test.sh | 64 ++++++++++++- .../adapters/jira/list-items.sh | 4 + .../adapters/jira/list-items.test.sh | 17 ++++ .../work-item-tracker/adapters/jira/mock.sh | 12 ++- 15 files changed, 242 insertions(+), 40 deletions(-) create mode 100644 .changes/work-items/feat-work-items-wont-do-followups-79e9c670.md diff --git a/.changes/work-items/feat-work-items-wont-do-followups-79e9c670.md b/.changes/work-items/feat-work-items-wont-do-followups-79e9c670.md new file mode 100644 index 0000000000..d62501901a --- /dev/null +++ b/.changes/work-items/feat-work-items-wont-do-followups-79e9c670.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- **Won't-do blockers.** The triage attention view has a fourth bucket, "blocked by won't-do": open items whose `blocked_by_wont_do_count` is above zero, whatever labels they carry, reported for the operator to drop the edge, re-scope, or close. The triage board accepts it as a `state`. The Jira adapter now reads done blockers' resolutions in one extra search and counts a blocker resolved as one of `config.jira.wont_do_resolutions` (team binding key, default `["Won't Do","Duplicate"]`, `[]` turns it off) in `blocked_by_count` and `blocked_by_wont_do_count`; a failed lookup keeps the done blocker blocking, as the GitHub adapter does. diff --git a/plugins/work-items/skills/setup/reference/providers.md b/plugins/work-items/skills/setup/reference/providers.md index 64ee16e95c..8906da7070 100644 --- a/plugins/work-items/skills/setup/reference/providers.md +++ b/plugins/work-items/skills/setup/reference/providers.md @@ -108,7 +108,7 @@ than starting from a blank file. |---|---|---| | `lease_ttl_hours` | every provider | Claim-lease lifetime in hours. RECOMMENDED `24`. | | `storage_dir` | `local-markdown` | The item-store directory. | -| `jira` | `jira` | `site` (Cloud host), non-empty `project_keys[]`, `auth_email`, `auth_env` (env-var NAME holding the token). Optional `blocked_by_link_type` / `done_category_keys` override the deferred live-instance defaults. | +| `jira` | `jira` | `site` (Cloud host), non-empty `project_keys[]`, `auth_email`, `auth_env` (env-var NAME holding the token). Optional `blocked_by_link_type` / `done_category_keys` override the deferred live-instance defaults; optional `wont_do_resolutions` (default `["Won't Do","Duplicate"]`) names the resolutions that make a done blocker won't-do. | | `gitea` | `gitea` | `host` (bare hostname), non-empty `scopes[]` (each `owner/repo`, the declared read scope **and** the authorization boundary), `auth_env`. Optional `page_size` (default 50, which you lower if the instance sets `api.MAX_RESPONSE_ITEMS` below that), `host_suffix` (your own egress pin; Gitea is self-hosted, so there is no vendor-domain default), `allow_custom_domain`. | | `linear` | `linear` | `host` (`api.linear.app`), non-empty `scopes[]` (each `/`, all sharing one workspace), `auth_env`. Optional `done_state_types` (which `WorkflowState.type` values count as closed; default `completed`/`canceled`/`duplicate`), `page_size`, `host_suffix`, `allow_custom_domain`. | diff --git a/plugins/work-items/skills/triage/SKILL.md b/plugins/work-items/skills/triage/SKILL.md index b491c7e4f1..3885feedf8 100644 --- a/plugins/work-items/skills/triage/SKILL.md +++ b/plugins/work-items/skills/triage/SKILL.md @@ -54,7 +54,7 @@ read it before applying that stamp in step 5. Three rules bound what enters this flow: - **A PR is an item with attached code.** An unsolicited or external PR enters the same intake as an issue: same states, same machine. Its diff is an **attachment to evaluate**, check it out, run the relevant tests, never an obligation to merge. Read the state names against the code: briefed means a brief exists for what to do with the diff; human-gated means a human should decide the merge. -- **Never re-triage already-triaged output.** Items born triaged. Published by `/work-items:decompose`, or created by a `/work-items:track add` that leaves no raw marker. Already carry a routing decision. They never re-enter this flow, and the attention view excludes them by construction (being neither unlabeled nor marked with the raw marker, they fall in none of its buckets). This exclusion keys on **absence of the raw marker**, not authorship and not the mere presence of classification labels: the raw marker (bare `needs-triage`) or being unlabeled puts an item in scope even alongside default labels, so a team-authored dogfood issue filed with a default `priority:` label *and* the raw marker is in scope (the marker wins), while a `track add` item that carries classification labels but no raw marker is out of scope for the same reason decompose output is. If someone names an already-triaged item explicitly, say it is already triaged and stop. +- **Never re-triage already-triaged output.** Items born triaged. Published by `/work-items:decompose`, or created by a `/work-items:track add` that leaves no raw marker. Already carry a routing decision. They never re-enter this flow, and the attention view excludes them by construction (being neither unlabeled nor marked with the raw marker, they fall in none of its intake buckets; only the blocked-by-won't-do bucket can list one, as a report for the operator). This exclusion keys on **absence of the raw marker**, not authorship and not the mere presence of classification labels: the raw marker (bare `needs-triage`) or being unlabeled puts an item in scope even alongside default labels, so a team-authored dogfood issue filed with a default `priority:` label *and* the raw marker is in scope (the marker wins), while a `track add` item that carries classification labels but no raw marker is out of scope for the same reason decompose output is. If someone names an already-triaged item explicitly, say it is already triaged and stop. - **Lane infrastructure is never intake.** The loop-lane convention's per-lane telemetry tracking issues, the surfaces holding that convention's sentinel-marked status comment, are lane infrastructure, not backlog: an open one is a lane operating. **Identify one the way the lane resolves its own telemetry home**, never by title alone: the issue the lane's launch config pins (`lanes[].telemetry.issue` in the `harness-ops` lane config, read from `/.work/lanes/lanes.json`, or from `/.work/lanes.json` when only that file exists), else the default `Lane telemetry: ` title (`/work-items:work-loop`, "Telemetry and durable loop state"); and, independent of both, **any issue carrying the convention's sentinel status comment** (``). The two signals cover each other: a config pinned to an operator-titled issue defeats the title test, and an issue pinned but not yet written to carries no sentinel, a title-only test admits exactly the first case and then relabels or closes the surface holding durable lane state. **Also exclude `work-map` container items**. Ordinary open issues carrying the tracker seam's container label (`WIT_CONTAINER_LABEL`, default `work-map`): they are never claimable frontier work (`list-frontier` drops them unconditionally per the seam contract) and their openness means the map exists, not that backlog is waiting. The exclusion never keys on labels either for telemetry (since the raw marker rides in as a creation-time filing default and a lane can re-add it at any cycle, so it holds **whatever labels they carry, the raw marker included**). A telemetry issue never enters the attention view, and one named explicitly is reported as lane infrastructure and stopped on, never state-machined, relabeled, or closed, since the lane reads that surface to operate. Container items are filtered from the attention view the same way. The lanes' own snapshots exclude the same populations by pointing here; it is defined here because this skill defines the intake population every lane composes. ## Triage states @@ -90,13 +90,14 @@ Claiming stays coordination state, not a label. Assignee + lease via the seam (` ## Attention view (no number) -Show three buckets (oldest first, one-line summaries): +Show four buckets (oldest first, one-line summaries): 1. **Unlabeled**, never triaged 2. **Raw marker**. bare `needs-triage`. Explicitly tagged for evaluation 3. **`status:needs-info` with reporter activity**. Reporter replied since last triage note; ready for re-evaluation +4. **Blocked by won't-do**. Open items whose `blocked_by_wont_do_count` is above zero: a blocker was closed as won't-do, so the item waits on work that will not happen and never reaches the frontier on its own. Report it for the operator to decide (drop the edge, re-scope, or close); an already-triaged item is not re-run through the state machine. The field and which close reasons count per adapter: `${CLAUDE_PLUGIN_ROOT}/tools/work-item-tracker/CONTRACT.md` "JSON output contract". An adapter that cannot read close reasons reports `0`, so this bucket stays empty there -List open items and filter into buckets programmatically (adapter: "List items", bare read). Apply the lane-infrastructure exclusion ("Scope: raw intake only") to that listing **before** bucketing, so a telemetry issue carrying the raw marker is filtered out rather than bucketed under it. **Defensive skip:** drop any item that already carries a native `blocked-by` edge *and* a prior triage comment (machine disclaimer or structured needs-info template), a stray re-label from another lane must not cost a full re-investigation. When the repo treats external PRs as a request surface, include them and tag each line `[PR]` or `[issue]`, but surface only *external* PRs (a collaborator's in-flight PR is not triage work; this filter is discovery-only, and an explicitly named PR is always triaged regardless of author). Present as a compact table. +List open items and filter into buckets programmatically (adapter: "List items", bare read). Apply the lane-infrastructure exclusion ("Scope: raw intake only") to that listing **before** bucketing, so a telemetry issue carrying the raw marker is filtered out rather than bucketed under it. Bucket 4 reads the count from the listing rows, whatever labels the item carries: an item already triaged still lands there, and one that also fits buckets 1-3 is listed once, under the earlier bucket. **Defensive skip** (buckets 1-3 only): drop any item that already carries a native `blocked-by` edge *and* a prior triage comment (machine disclaimer or structured needs-info template), a stray re-label from another lane must not cost a full re-investigation. When the repo treats external PRs as a request surface, include them and tag each line `[PR]` or `[issue]`, but surface only *external* PRs (a collaborator's in-flight PR is not triage work; this filter is discovery-only, and an explicitly named PR is always triaged regardless of author). Present as a compact table. ### Board page diff --git a/plugins/work-items/skills/triage/context/board.md b/plugins/work-items/skills/triage/context/board.md index 389303e656..81e5e0f9a4 100644 --- a/plugins/work-items/skills/triage/context/board.md +++ b/plugins/work-items/skills/triage/context/board.md @@ -53,7 +53,8 @@ node "/skills/triage/scripts/build-board.mjs" --out "/triage-b {"number":1,"title":"","kind":"issue","state":"unlabeled","labels":[""],"blockedBy":[2]}]} ``` -- `state` is the attention-view bucket the item came from: `unlabeled`, `raw marker`, or `needs-info reply`. +- `state` is the attention-view bucket the item came from: `unlabeled`, `raw marker`, `needs-info reply`, or + `blocked by won't-do`. - `kind` is `issue` or `PR`. - Give `blockedBy` (the issue numbers on the item's native blocked-by edges) only when the listing read them. Leave it out otherwise: the board then groups the item under "blockers not read" instead diff --git a/plugins/work-items/skills/triage/evals/evals.json b/plugins/work-items/skills/triage/evals/evals.json index 0be7c1f67c..42425843a5 100644 --- a/plugins/work-items/skills/triage/evals/evals.json +++ b/plugins/work-items/skills/triage/evals/evals.json @@ -39,7 +39,7 @@ "Does NOT re-run the triage state machine on a decompose-published item — such items are born triaged (classified, role-labeled, briefed at creation)", "States the exclusion rule: triage covers raw intake only — untriaged items carrying the raw marker; the exclusion keys on absence of the raw marker (born triaged), not authorship and not the mere presence of classification labels", "Performs no triage writes on the item — no label changes, no needs-info comment, no close", - "Notes the attention view excludes decompose output by construction — it carries a routing decision and no raw marker, so being neither unlabeled nor raw-marked it falls in none of the view's buckets" + "Notes the attention view excludes decompose output by construction — it carries a routing decision and no raw marker, so being neither unlabeled nor raw-marked it falls in none of the view's intake buckets" ] }, { @@ -189,6 +189,20 @@ "Leaves blockedBy out for an item whose blockers it did not read instead of reporting it unblocked" ], "id": 14 + }, + { + "id": 15, + "name": "triage-attention-view-surfaces-items-blocked-by-wont-do", + "prompt": "/work-items:triage\n\nInteractive session. The listing returns three open issues: #61 is unlabeled with blocked_by_wont_do_count 0; #62 carries status:ready and agent-ready, has a prior triage comment, and has blocked_by_count 1 and blocked_by_wont_do_count 1 because its only blocker #58 was closed as not planned; #63 carries needs-triage and has blocked_by_wont_do_count 0.", + "expected_output": "The attention view shows four buckets. #61 is under Unlabeled and #63 under Raw marker. #62 is under Blocked by won't-do even though it is already triaged and has a prior triage comment, because that bucket reads blocked_by_wont_do_count from the listing whatever labels the item carries and the defensive skip covers only the first three buckets. The line for #62 says its blocker closed as won't-do, so it waits on work that will not happen, and leaves the choice to the operator (drop the edge, re-scope, or close). It does not run #62 through the triage state machine and changes no label, edge or state.", + "files": [], + "expectations": [ + "Lists #62 under a blocked-by-won't-do bucket, read from blocked_by_wont_do_count above zero", + "Does not drop #62 by the defensive skip or because it already carries routing labels", + "Keeps #61 under Unlabeled and #63 under Raw marker, each listed once", + "Says #62 waits on a blocker closed as won't-do and leaves dropping the edge, re-scoping or closing to the operator", + "Applies no label, edge or state change and does not re-run the triage state machine on #62" + ] } ] } diff --git a/plugins/work-items/skills/triage/scripts/build-board.mjs b/plugins/work-items/skills/triage/scripts/build-board.mjs index 7a5c46df31..3288d0235f 100755 --- a/plugins/work-items/skills/triage/scripts/build-board.mjs +++ b/plugins/work-items/skills/triage/scripts/build-board.mjs @@ -3,7 +3,8 @@ // // {"repo":"","generated":"","items":[{"number":1,"title":"","kind":"issue","state":"","labels":[""],"blockedBy":[2]}]} // -// state is the attention-view bucket; omit blockedBy when blockers were not read. +// state is the attention-view bucket (unlabeled, raw marker, needs-info reply, blocked by +// won't-do); omit blockedBy when blockers were not read. // // build-board.mjs --out writes the page, prints its path // build-board.mjs --out /page.html --connect http://127.0.0.1: diff --git a/plugins/work-items/tests/triage-board.test.sh b/plugins/work-items/tests/triage-board.test.sh index 192c499b90..57afb6104c 100755 --- a/plugins/work-items/tests/triage-board.test.sh +++ b/plugins/work-items/tests/triage-board.test.sh @@ -83,6 +83,11 @@ build({ items: [{ number: 1, title: "t" }] }); const bare = JSON.parse(/id="rv-data">([^]*?)<\/script>/.exec(readFileSync(`${work}/board.html`, "utf8"))[1]); check("an item with no labels and no state groups as no label, untriaged", bare.bylabel[0].name === "no label" && bare.bystate[0].name === "untriaged"); check("an item whose blockers were not read is neither blocked nor unblocked", bare.byblocker[0].name === "blockers not read"); +const wontDo = build({ items: [{ number: 62, title: "t", state: "blocked by won't-do", blockedBy: [58] }, { number: 61, title: "u", state: "unlabeled" }] }); +const wontDoPage = readFileSync(`${work}/board.html`, "utf8"); +const wontDoData = JSON.parse(/id="rv-data">([^]*?)<\/script>/.exec(wontDoPage)[1]); +check("the blocked-by-won't-do bucket groups as its own state", wontDo.status === 0 && wontDoData.bystate.some((g) => g.name === "blocked by won't-do" && g.srows[0].ref === "#62"), wontDo.stderr); +check("a board with the won't-do bucket passes the interactive profile", validateView(wontDoPage).ok, validateView(wontDoPage).failures); check( "the act list holds every item in input order, ref and title only", JSON.stringify(data.items) === JSON.stringify(items.slice(0, 3).map((item, i) => ({ ref: `#${i + 1}`, title: item.title }))), diff --git a/plugins/work-items/tools/work-item-tracker/CONTRACT.md b/plugins/work-items/tools/work-item-tracker/CONTRACT.md index 52ac598270..16c5b3e64d 100644 --- a/plugins/work-items/tools/work-item-tracker/CONTRACT.md +++ b/plugins/work-items/tools/work-item-tracker/CONTRACT.md @@ -333,14 +333,16 @@ Normalized item object: | GitHub | `stateReason` `COMPLETED` or `null` | `NOT_PLANNED`, `DUPLICATE` | | Linear | state type `completed`, while `done_state_types` lists it | other done types (`canceled`, `duplicate`) | | local-markdown | `closed`, no `state_reason` or `completed` | any other `state_reason` (`not_planned`, `duplicate`, ...) | - | Jira, Gitea | any closed blocker (won't-do detection unsupported) | none | + | Jira | done-category blocker whose resolution is not in `wont_do_resolutions` | resolution in `wont_do_resolutions` (default `Won't Do`, `Duplicate`) | + | Gitea | any closed blocker (won't-do detection unsupported) | none | GitHub: `blockedBy.totalCount` keeps counting closed blockers and the `gh --json blockedBy` projection has no `stateReason`, so the adapter reads each closed blocker's `stateReason` through `gh api graphql`. When that query fails, or returns no node for a blocker, the closed blocker keeps blocking (fail closed) without counting as won't-do, and a warning goes to stderr. A `null` reason is an issue closed before GitHub recorded - reasons. + reasons. Jira reads done blockers' resolutions the same way and fails closed the same + way ("jira adapter" below). - `parent_id` is a fully-qualified ID or `null`. Bulk `list-items` rows MAY carry `parent_id: null` when the provider's list surface omits parent data (GitHub's does); `get-item` is authoritative for parent linkage. @@ -701,7 +703,8 @@ PR `SW2-*` linkage and the opt-in-write mechanism are sequenced follow-ups. "auth_email": "ci@company.com", "auth_env": "JIRA_API_TOKEN", "blocked_by_link_type": "Blocks", - "done_category_keys": ["done", "completed"] + "done_category_keys": ["done", "completed"], + "wont_do_resolutions": ["Won't Do", "Duplicate"] } } } @@ -721,16 +724,28 @@ PR `SW2-*` linkage and the opt-in-write mechanism are sequenced follow-ups. for two facts deferred to a live-instance pass: the authoritative blocker link type and the exact `statusCategory` key for the "Done" category (the official spec's own example disagrees with real instances, and both known keys are defaulted so the adapter is independent of that - deferred fact). + deferred fact). `wont_do_resolutions` (default `["Won't Do", "Duplicate"]`, matched + case-insensitively; a present value must be an array of non-empty strings, exit `3`; `[]` + turns won't-do detection off) names the resolutions that mark a done blocker as won't-do. + Jira admins can rename and add resolutions + ([add, edit, or delete resolutions](https://support.atlassian.com/jira-cloud-administration/docs/add-edit-or-delete-resolutions/), + as of 2026-10-10; recheck when an instance's resolution names change), so the names are a + team binding key, not plugin config. `Duplicate` is in the default because the GitHub + adapter counts a `DUPLICATE` close as won't-do. - **Read-path normalization** (CONTRACT.md "JSON output contract"): `state` is `closed` when the `statusCategory` key is in `done_category_keys`, else `open`; `assignees` is the single `assignee`'s `accountId` as a one-element array (empty when unassigned); `labels` is Jira `labels[]` verbatim (canonical role labels ride as ordinary labels; `list-frontier --autonomous` filters them core-side); `type` is the issue-type name; `blocked_by_count` counts **open** inward - `blocked_by_link_type` links only (the linked issue's status is inlined in `issuelinks`, so - no second round-trip); won't-do detection is unsupported, because `issuelinks` carries no - resolution, so `blocked_by_wont_do_count` is `0` (a follow-up could fetch done blockers' - resolutions under a binding key naming the completed ones); `parent_id` comes from + `blocked_by_link_type` links (the linked issue's status is inlined in `issuelinks`) plus + done ones whose resolution is in `wont_do_resolutions`, and `blocked_by_wont_do_count` + counts those won't-do ones. `issuelinks` carries no resolution, so after normalizing a + page (or one issue) the adapter reads every done blocker's resolution in one + `POST /search/jql` (`key in (...)`, `fields: ["resolution"]`, up to 100 keys per query), + skipped when there is no done blocker or the list is empty. Only blockers in + `project_keys` are looked up; a done blocker in another project stays resolved. When the + lookup fails, or does not return a blocker, that done blocker keeps blocking (fail closed) + without counting as won't-do, and a warning goes to stderr; `parent_id` comes from `fields.parent` (subtask→parent universally, story→epic where the instance uses the unified parent field rather than the legacy Epic-Link custom field, a documented best-effort limitation deferred with the sub-item link-type question); `url` is `https:///browse/`. diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/README.md b/plugins/work-items/tools/work-item-tracker/adapters/jira/README.md index 225b0e20fe..8ddf042b85 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/README.md +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/README.md @@ -44,13 +44,15 @@ error). `list-frontier --parent` is unavailable (needs `list-sub-items`, which i `work-item-tracker.sh get-item jira:/#` returns the normalized item object (CONTRACT.md "JSON output contract"): `state`, `assignees`, `labels`, `type`, -`blocked_by_count`, `blocked_by_wont_do_count`, `parent_id`, `url`. `blocked_by_count` counts only +`blocked_by_count`, `blocked_by_wont_do_count`, `parent_id`, `url`. `blocked_by_count` counts **open** inward blockers under the configured link type (`config.jira.blocked_by_link_type`, -default `Blocks`). **Won't-do detection is unsupported:** an issue link carries the blocker's -status but not its resolution, so a done blocker counts as resolved and -`blocked_by_wont_do_count` is always `0`. Supporting it would mean fetching each done blocker's -resolution plus a binding key naming the resolutions that mean completed (a noted follow-up, not -built). +default `Blocks`) plus done blockers resolved as won't-do, which `blocked_by_wont_do_count` +counts again. An issue link carries the blocker's status but not its resolution, so the adapter +reads done blockers' resolutions in one extra search per call (per page for `list-items`) and +matches them against `config.jira.wont_do_resolutions` (default `["Won't Do","Duplicate"]`, +case-insensitive; `[]` turns the lookup off). Set it in the team binding when your admins have +renamed or added resolutions. A failed lookup keeps the done blockers blocking; blockers outside +`project_keys` are not looked up and stay resolved. CONTRACT.md "jira adapter" has the rules. ## Auth & prerequisites diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh index 745ddfa8fe..41d788820a 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh @@ -65,6 +65,12 @@ readonly WIT_JIRA_DEFAULT_BLOCKED_BY_LINK_TYPE="Blocks" # does not flow into an exported variable as shell-quoted text (would trip SC2089/2090). WIT_JIRA_DEFAULT_DONE_KEYS="$(jq -cn '["done","completed"]')" readonly WIT_JIRA_DEFAULT_DONE_KEYS +# Resolutions that mark a done blocker as won't-do, matched case-insensitively. Jira +# admins can rename or add resolutions, so the binding may override the list; an empty +# list turns won't-do detection off. "Duplicate" is in the default because the GitHub +# adapter counts a DUPLICATE close as won't-do. +WIT_JIRA_DEFAULT_WONT_DO_RESOLUTIONS="$(jq -cn '["Won\u0027t Do","Duplicate"]')" +readonly WIT_JIRA_DEFAULT_WONT_DO_RESOLUTIONS # Values interpolated into a JQL query (project keys, statusCategory keys) are # constrained to a strict allowlist BEFORE interpolation — the seam builds JQL by @@ -75,6 +81,9 @@ readonly WIT_JIRA_DEFAULT_DONE_KEYS # letter-led alphanumerics plus hyphen (e.g. `in-flight`). Anything else is refused. readonly WIT_JIRA_PROJECT_KEY_RE='^[A-Za-z][A-Za-z0-9_]*$' readonly WIT_JIRA_CATEGORY_KEY_RE='^[A-Za-z][A-Za-z0-9-]*$' +# Blocker issue keys go into the resolution lookup's `key in (...)` clause, so they get +# the same allowlist treatment. +readonly WIT_JIRA_ISSUE_KEY_RE='^[A-Za-z][A-Za-z0-9_]*-[0-9]+$' # config.jira.site becomes the request host that receives the Basic-auth token, so it # must be a BARE hostname — no scheme, path, userinfo (`@`), port, or control chars — @@ -136,7 +145,8 @@ wit_require_jira_id() { # config-free (mirrors local-markdown's wit_need_storage). Exits 3 when the binding # or a required key (site, non-empty project_keys, auth_email, auth_env) is missing. # Exports WIT_JIRA_SITE, WIT_JIRA_PROJECT_KEYS (JSON array), WIT_JIRA_AUTH_EMAIL, -# WIT_JIRA_AUTH_ENV, WIT_JIRA_BLOCKED_BY_LINK_TYPE, WIT_JIRA_DONE_KEYS (JSON array). +# WIT_JIRA_AUTH_ENV, WIT_JIRA_BLOCKED_BY_LINK_TYPE, WIT_JIRA_DONE_KEYS (JSON array), +# WIT_JIRA_WONT_DO_RESOLUTIONS (JSON array). wit_need_jira_config() { local name binding ejson name="$(basename "${BASH_SOURCE[1]}")" @@ -167,6 +177,12 @@ wit_need_jira_config() { else WIT_JIRA_DONE_KEYS="$WIT_JIRA_DEFAULT_DONE_KEYS" fi + # Same presence read: a present value is judged below, never collapsed to the default. + if [[ "$(jq -r '.config.jira | has("wont_do_resolutions")' <<<"$ejson")" == "true" ]]; then + WIT_JIRA_WONT_DO_RESOLUTIONS="$(jq -c '.config.jira.wont_do_resolutions' <<<"$ejson")" + else + WIT_JIRA_WONT_DO_RESOLUTIONS="$WIT_JIRA_DEFAULT_WONT_DO_RESOLUTIONS" + fi local allow_custom_domain allow_custom_domain="$(jq -r '.config.jira.allow_custom_domain // false' <<<"$ejson")" local missing="" @@ -212,6 +228,10 @@ wit_need_jira_config() { # already collapsed null into the default string). [[ "$(jq -r '(.config.jira.blocked_by_link_type) as $b | if $b == null then "default" elif (($b|type)=="string" and ($b|length)>0) then "ok" else "bad" end' <<<"$ejson")" != "bad" ]] || bad+=" blocked_by_link_type(must be a non-empty string)" + # wont_do_resolutions is matched in jq, never interpolated into JQL, so any non-empty + # string is a valid name; an empty array is the explicit opt-out. + [[ "$(jq -r 'if type == "array" and all(.[]; type == "string" and length > 0) then "ok" else "bad" end' <<<"$WIT_JIRA_WONT_DO_RESOLUTIONS")" == "ok" ]] || + bad+=" wont_do_resolutions(must be an array of non-empty strings)" # Every element (each project key, each done key) is checked IN jq so an empty or # non-string element cannot slip through — a bash line-loop loses trailing empty # elements to command-substitution newline stripping, so `[""]` would evade it. @@ -235,7 +255,7 @@ wit_need_jira_config() { exit "$EX_CONFIG" } export WIT_JIRA_SITE WIT_JIRA_AUTH_EMAIL WIT_JIRA_AUTH_ENV WIT_JIRA_PROJECT_KEYS \ - WIT_JIRA_BLOCKED_BY_LINK_TYPE WIT_JIRA_DONE_KEYS + WIT_JIRA_BLOCKED_BY_LINK_TYPE WIT_JIRA_DONE_KEYS WIT_JIRA_WONT_DO_RESOLUTIONS } # wit_jira_project_in_scope — 0 when the key is in the binding's @@ -334,22 +354,73 @@ readonly WIT_JIRA_FIELDS="status,assignee,labels,issuetype,parent,issuelinks,sum # file sees them as intentionally external. export WIT_JIRA_FIELDS +# wit_jira_blocker_resolutions — stdin: one raw Jira IssueBean or an array of them; +# stdout: a JSON object keyed by done inward-blocker key, each {read: true, name: +# } or {read: false}. An issue link carries the blocker's +# status but not its resolution, so the resolutions come from POST /search/jql with +# fields=resolution, one query per 100 keys and none when there is no done blocker or +# config.jira.wont_do_resolutions is empty. Only blockers in config.jira.project_keys +# are looked up (the declared read scope); others are left out and stay resolved. A +# failed query, or a key the search did not return, leaves {read: false}, which the +# normalizer keeps blocking (fail closed); a warning goes to stderr. +wit_jira_blocker_resolutions() { + local json keys n i chunk jql body out page res + json="$(cat)" + if [[ "$(jq 'length' <<<"$WIT_JIRA_WONT_DO_RESOLUTIONS")" == "0" ]]; then + printf '{}' + return 0 + fi + keys="$(jq -c --arg blk "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" --argjson dk "$WIT_JIRA_DONE_KEYS" \ + --argjson pk "$WIT_JIRA_PROJECT_KEYS" --arg re "$WIT_JIRA_ISSUE_KEY_RE" ' + [(if type == "array" then .[] else . end) | (.fields.issuelinks // [])[] + | select(.type.name == $blk and .inwardIssue != null) | .inwardIssue + | select(.fields.status.statusCategory.key as $bk | ($dk | index($bk)) != null) | .key + | select(type == "string" and test($re)) | select(split("-")[0] as $p | ($pk | index($p)) != null)] + | unique' <<<"$json")" + res="$(jq -c 'map({key: ., value: {read: false}}) | from_entries' <<<"$keys")" + n="$(jq 'length' <<<"$keys")" + for ((i = 0; i < n; i += 100)); do + chunk="$(jq -c --argjson i "$i" '.[$i:$i + 100]' <<<"$keys")" + jql="key in ($(jq -r 'map("\"\(.)\"") | join(",")' <<<"$chunk"))" + body="$(jq -cn --arg jql "$jql" --argjson mr "$(jq 'length' <<<"$chunk")" \ + '{jql: $jql, fields: ["resolution"], maxResults: $mr}')" + if out="$(wit_jira_http POST "/search/jql" "$body" 2>/dev/null && [[ "$WIT_JIRA_STATUS" == 2* ]] && + printf '%s' "$WIT_JIRA_BODY")" && + page="$(jq -ce 'if (.issues | type) == "array" + then [.issues[] | {key: .key, value: {read: true, name: (.fields.resolution.name // null)}}] | from_entries + else error("no issues array") end' <<<"$out" 2>/dev/null)"; then + res="$(jq -cn --argjson acc "$res" --argjson page "$page" \ + '$acc + ($page | with_entries(select(.key as $k | $acc | has($k))))')" + else + echo "wit_jira_blocker_resolutions: resolution query failed; done blockers keep blocking" >&2 + fi + done + printf '%s' "$res" +} + # wit_jira_normalize_program — a jq program that maps one raw Jira IssueBean into the # seam's normalized item object (CONTRACT.md "JSON output contract"). Expects jq args # $sv (schema version), $site (Cloud host), $dk (JSON array of done statusCategory -# keys), $blk (blocker link-type name). blocked_by_count counts only OPEN inward -# blockers. An issue link carries the blocker's status but not its resolution, so a -# done blocker counts as resolved and won't-do detection is unsupported: -# blocked_by_wont_do_count is always 0. +# keys), $blk (blocker link-type name), $wd (config.jira.wont_do_resolutions), $res +# (wit_jira_blocker_resolutions output). blocked_by_count counts OPEN inward blockers, +# done blockers whose resolution is in $wd, and done blockers whose resolution lookup +# failed; blocked_by_wont_do_count counts the ones whose resolution is in $wd. # WIT_JIRA_NORMALIZE_PROGRAM is consumed by the sourcing verb scripts (get-item, # list-items), not within this file — SC2034 is a false positive on a sourced-only # lib. It is a jq program string, so it is not exported (an exported quoted program # trips SC2089/2090); the disable marks it intentionally external instead. -# shellcheck disable=SC2016,SC2034 # jq program — $sv/$site/$dk/$blk are jq args, not bash; used by verb scripts +# shellcheck disable=SC2016,SC2034 # jq program — $sv/$site/$dk/$blk/$wd/$res are jq args, not bash; used by verb scripts readonly WIT_JIRA_NORMALIZE_PROGRAM=' def keyparts: (capture("^(?

[A-Za-z][A-Za-z0-9_]*)-(?[0-9]+)$") // error("jira: unparsable issue key: " + (. | tostring))); def qualify: (keyparts | "jira:" + $site + "/" + .p + "#" + .n); + def blockers: [(.fields.issuelinks // [])[] + | select(.type.name == $blk and (.inwardIssue != null)) | .inwardIssue]; + def done: (.fields.status.statusCategory.key) as $bk | ($dk | index($bk)) != null; + def lookup: .key as $k | ($res[$k] // {read: true, name: null}); + def wontdo: lookup as $l | $l.read and ($l.name | type) == "string" + and ([$wd[] | ascii_downcase] | index($l.name | ascii_downcase)) != null; + def unread: lookup | .read | not; { schema_version: $sv, id: (.key | qualify), @@ -359,11 +430,8 @@ readonly WIT_JIRA_NORMALIZE_PROGRAM=' assignees: [ .fields.assignee.accountId // empty ], labels: (.fields.labels // []), type: (.fields.issuetype.name // null), - blocked_by_count: ([ (.fields.issuelinks // [])[] - | select(.type.name == $blk and (.inwardIssue != null)) - | (.inwardIssue.fields.status.statusCategory.key) as $bk - | select( ($dk | index($bk)) | not ) ] | length), - blocked_by_wont_do_count: 0, + blocked_by_count: ([blockers[] | select((done | not) or unread or wontdo)] | length), + blocked_by_wont_do_count: ([blockers[] | select(done and wontdo)] | length), parent_id: (if (.fields.parent.key // null) == null then null else (.fields.parent.key | qualify) end), url: ("https://" + $site + "/browse/" + .key) }' diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.sh index ecc4047080..3242c87c1e 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.sh @@ -43,11 +43,16 @@ wit_jira_require_ok "get-item $id" exit "$EX_UNAVAILABLE" } +# Done blockers' resolutions decide which of them are won't-do (a second request only +# when the issue has a done blocker; a failed lookup keeps those blockers blocking). +res="$(wit_jira_blocker_resolutions <<<"$WIT_JIRA_BODY")" + # Normalize with an explicit exit check: a body whose key the parser rejects makes jq # fail, which would otherwise surface as a bare non-zero exit — map it to exit 8 (the # provider returned an issue we cannot represent) with a clear message. jq -c --arg sv "$WIT_SCHEMA_VERSION" --arg site "$WIT_JIRA_SITE" \ --argjson dk "$WIT_JIRA_DONE_KEYS" --arg blk "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" \ + --argjson wd "$WIT_JIRA_WONT_DO_RESOLUTIONS" --argjson res "$res" \ "$WIT_JIRA_NORMALIZE_PROGRAM" <<<"$WIT_JIRA_BODY" || { printf 'get-item: %s response could not be normalized (malformed issue key?)\n' "$WIT_JIRA_STATUS" >&2 exit "$EX_UNAVAILABLE" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.test.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.test.sh index 0b2498c3fc..453d1b9d11 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.test.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.test.sh @@ -28,6 +28,9 @@ cat >"$JIRA_FIX/1.body" <<'JSON' {"key":"SW2-12345","fields":{"summary":"Do the thing","status":{"statusCategory":{"key":"indeterminate"}},"assignee":{"accountId":"acc-1"},"labels":["backend","urgent"],"issuetype":{"name":"Task"},"parent":{"key":"SW2-100"},"issuelinks":[{"type":{"name":"Blocks"},"inwardIssue":{"key":"SW2-9","fields":{"status":{"statusCategory":{"key":"new"}}}}},{"type":{"name":"Blocks"},"inwardIssue":{"key":"SW2-8","fields":{"status":{"statusCategory":{"key":"done"}}}}}]}} JSON printf '200' >"$JIRA_FIX/1.status" +# Call 2 is the resolution lookup for the done blocker SW2-8: resolved as Done. +printf '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"Done"}}}]}' >"$JIRA_FIX/2.body" +printf '200' >"$JIRA_FIX/2.status" jira_run "$S" "jira:test.atlassian.net/SW2#12345" assert_eq "get-item happy path exit 0" "0" "$RC" @@ -38,11 +41,68 @@ assert_eq "state open (indeterminate)" "open" "$(jq -r '.state' <<<"$OUT")" assert_eq "assignee accountId" "acc-1" "$(jq -r '.assignees[0]' <<<"$OUT")" assert_eq "labels verbatim" "backend,urgent" "$(jq -r '.labels | join(",")' <<<"$OUT")" assert_eq "type name" "Task" "$(jq -r '.type' <<<"$OUT")" -assert_eq "blocked_by_count OPEN-only" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" -assert_eq "blocked_by_wont_do_count is 0 (resolution unreadable)" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +assert_eq "blocked_by_count: a Done-resolved blocker is resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "blocked_by_wont_do_count 0 for a Done resolution" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" assert_eq "parent_id qualified" "jira:test.atlassian.net/SW2#100" "$(jq -r '.parent_id' <<<"$OUT")" assert_eq "url is browse link" "https://test.atlassian.net/browse/SW2-12345" "$(jq -r '.url' <<<"$OUT")" case "$OUT" in *$'\r'*) fail "stdout CR-free" "no CR" "CR present" ;; *) pass "stdout CR-free" ;; esac +lookup="$(awk '/^--data$/{getline; print; exit}' "$JIRA_FIX/2.args")" +assert_eq "resolution lookup JQL names only the done blocker" 'key in ("SW2-8")' "$(jq -r '.jql' <<<"$lookup")" +assert_eq "resolution lookup asks for the resolution field" '["resolution"]' "$(jq -c '.fields' <<<"$lookup")" + +# --- won't-do: a done blocker whose resolution is in wont_do_resolutions --- +# with_lookup — run the happy-path issue with +# call 2 answered by the given body and status; sets OUT. +with_lookup() { + printf '%s' "$1" >"$JIRA_FIX/2.body" + printf '%s' "$2" >"$JIRA_FIX/2.status" + jira_run "$S" "jira:test.atlassian.net/SW2#12345" +} +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"Won\u0027t Do"}}}]}' 200 +assert_eq "Won't Do blocker keeps blocking" "2" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "Won't Do blocker is counted as won't-do" "1" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"duplicate"}}}]}' 200 +assert_eq "Duplicate matches case-insensitively" "1" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":null}}]}' 200 +assert_eq "a done blocker with no resolution is resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" + +# A failed lookup, or one that does not return the blocker, fails closed: the done +# blocker keeps blocking without counting as won't-do, and the item still emits. +with_lookup '{"errorMessages":["boom"]}' 500 +assert_eq "failed lookup still exits 0" "0" "$RC" +assert_eq "failed lookup keeps the done blocker blocking" "2" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "failed lookup counts no won't-do" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +with_lookup '{"issues":[]}' 200 +assert_eq "a blocker the lookup did not return keeps blocking" "2" "$(jq -r '.blocked_by_count' <<<"$OUT")" + +# A binding override names the instance's own resolutions; an empty list turns the +# lookup off, so the done blocker is resolved with no second request. +jira_write_binding '["SW2"]' '{"wont_do_resolutions":["Abandoned"]}' +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"Abandoned"}}}]}' 200 +assert_eq "an overridden resolution name counts as won't-do" "1" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"Won\u0027t Do"}}}]}' 200 +assert_eq "the override replaces the default list" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +jira_write_binding '["SW2"]' '{"wont_do_resolutions":[]}' +jira_run "$S" "jira:test.atlassian.net/SW2#12345" +assert_eq "empty wont_do_resolutions makes one request" "1" "$(cat "$JIRA_FIX/.counter")" +assert_eq "empty wont_do_resolutions leaves the done blocker resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" +jira_write_binding '["SW2"]' '{"wont_do_resolutions":"Won\u0027t Do"}' +jira_run "$S" "jira:test.atlassian.net/SW2#12345" +assert_eq "non-array wont_do_resolutions → config (3)" "3" "$RC" +jira_write_binding '["SW2"]' '{"wont_do_resolutions":[""]}' +jira_run "$S" "jira:test.atlassian.net/SW2#12345" +assert_eq "empty-string wont_do_resolutions element → config (3)" "3" "$RC" + +# A done blocker outside project_keys is never looked up (the declared read scope) and +# stays resolved. +jira_write_binding '["SW2"]' +cat >"$JIRA_FIX/1.body" <<'JSON' +{"key":"SW2-12346","fields":{"summary":"Cross-project","status":{"statusCategory":{"key":"new"}},"assignee":null,"labels":[],"issuetype":{"name":"Task"},"parent":null,"issuelinks":[{"type":{"name":"Blocks"},"inwardIssue":{"key":"OTHER-3","fields":{"status":{"statusCategory":{"key":"done"}}}}}]}} +JSON +jira_run "$S" "jira:test.atlassian.net/SW2#12346" +assert_eq "out-of-scope done blocker makes no lookup" "1" "$(cat "$JIRA_FIX/.counter")" +assert_eq "out-of-scope done blocker is resolved" "0" "$(jq -r '.blocked_by_count' <<<"$OUT")" +rm -f "$JIRA_FIX/2.body" "$JIRA_FIX/2.status" # --- state mapping: a done-category issue normalizes to closed --- cat >"$JIRA_FIX/1.body" <<'JSON' diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh index 5b85643791..cfaf1e8a32 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh @@ -107,8 +107,12 @@ emitted=0 # stand — an all-invalid page would then collapse to items:[] with exit 0, telling # list-frontier there is no work. On any normalizer failure, fail loud (exit 8) # instead of emitting a partial page. + # One resolution lookup per page covers every done blocker on it. It runs in a + # command substitution, so this page's WIT_JIRA_BODY is left intact. + res="$(jq -c '.issues' <<<"$WIT_JIRA_BODY" | wit_jira_blocker_resolutions)" page_items="$(jq -c --arg sv "$WIT_SCHEMA_VERSION" --arg site "$WIT_JIRA_SITE" \ --argjson dk "$WIT_JIRA_DONE_KEYS" --arg blk "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" \ + --argjson wd "$WIT_JIRA_WONT_DO_RESOLUTIONS" --argjson res "$res" \ ".issues[] | $WIT_JIRA_NORMALIZE_PROGRAM" <<<"$WIT_JIRA_BODY")" || { printf 'jira: list-items — an issue in a %s page could not be normalized (malformed issue key?)\n' "$WIT_JIRA_STATUS" >&2 exit "$EX_UNAVAILABLE" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.test.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.test.sh index ed5a8058e0..9d423b32eb 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.test.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.test.sh @@ -162,6 +162,23 @@ printf '200' >"$JIRA_FIX/1.status" rc_for_jira "valid blocked_by_link_type accepted" "0" \ '{site:"test.atlassian.net", project_keys:["SW2"], auth_email:"a@b", auth_env:"JIRA_TEST_TOKEN", blocked_by_link_type:"Blocked By"}' +# Won't-do blockers: one resolution lookup per page (call 2) covers every done blocker on +# it. ABC-7 was closed Won't Do and keeps blocking SW2-2; SW2-4 was closed Done. +cat >"$JIRA_FIX/1.body" <<'JSON' +{"issues":[{"key":"SW2-2","fields":{"summary":"Dependent","status":{"statusCategory":{"key":"new"}},"assignee":null,"labels":[],"issuetype":{"name":"Task"},"parent":null,"issuelinks":[{"type":{"name":"Blocks"},"inwardIssue":{"key":"ABC-7","fields":{"status":{"statusCategory":{"key":"done"}}}}}]}},{"key":"SW2-3","fields":{"summary":"Unblocked","status":{"statusCategory":{"key":"new"}},"assignee":null,"labels":[],"issuetype":{"name":"Task"},"parent":null,"issuelinks":[{"type":{"name":"Blocks"},"inwardIssue":{"key":"SW2-4","fields":{"status":{"statusCategory":{"key":"done"}}}}}]}}],"nextPageToken":null,"isLast":true} +JSON +printf '200' >"$JIRA_FIX/1.status" +printf '{"issues":[{"key":"ABC-7","fields":{"resolution":{"name":"Won\u0027t Do"}}},{"key":"SW2-4","fields":{"resolution":{"name":"Done"}}}]}' >"$JIRA_FIX/2.body" +printf '200' >"$JIRA_FIX/2.status" +jira_run "$S" --state open +assert_eq "won't-do page exit 0" "0" "$RC" +assert_eq "one search plus one resolution lookup" "2" "$(cat "$JIRA_FIX/.counter")" +assert_eq "lookup names both done blockers" 'key in ("ABC-7","SW2-4")' \ + "$(jq -r '.jql' <<<"$(awk '/^--data$/{getline; print; exit}' "$JIRA_FIX/2.args")")" +assert_eq "Won't Do blocker keeps its dependent blocked" "1/1" "$(jq -r '.items[0] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" +assert_eq "Done blocker resolves its dependent" "0/0" "$(jq -r '.items[1] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" +rm -f "$JIRA_FIX/2.body" "$JIRA_FIX/2.status" + # A single empty page (no token) terminates cleanly with an empty envelope. printf '{"issues":[],"nextPageToken":null,"isLast":true}' >"$JIRA_FIX/1.body" printf '200' >"$JIRA_FIX/1.status" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/mock.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/mock.sh index 8a073a54de..0f2927643a 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/mock.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/mock.sh @@ -31,14 +31,16 @@ MOCK chmod +x "$JIRA_FIX/curl" } -# jira_write_binding : a valid jira binding declaring those keys as -# the read scope. +# jira_write_binding []: a valid jira +# binding declaring those keys as the read scope, with any extra config.jira keys merged. jira_write_binding() { - jq -cn --argjson pk "$1" \ + local extra="${2:-}" + [[ -n "$extra" ]] || extra='{}' + jq -cn --argjson pk "$1" --argjson extra "$extra" \ '{schema_version:"1.0", provider:"jira", config:{lease_ttl_hours:24, - jira:{site:"test.atlassian.net", project_keys:$pk, - auth_email:"ci@test.example", auth_env:"JIRA_TEST_TOKEN"}}}' \ + jira:({site:"test.atlassian.net", project_keys:$pk, + auth_email:"ci@test.example", auth_env:"JIRA_TEST_TOKEN"} + $extra)}}' \ >"$JIRA_FIX/binding.json" } From b9e5b057874875057367ad467b4904ca48353121 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:32:36 -0400 Subject: [PATCH 2/3] fix(work-items): anchor Jira blocker-key check at end of string and tolerate a keyless blocker jq's `$` also matches before a trailing newline, so `\z` keeps a key with a newline out of the resolution lookup's JQL. A done blocker with no string key now falls back to the default lookup result instead of raising a jq error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AXBazvq2HJYYKarzkTHrng --- .../tools/work-item-tracker/adapters/jira/common.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh index 41d788820a..4676209ce7 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh @@ -83,7 +83,7 @@ readonly WIT_JIRA_PROJECT_KEY_RE='^[A-Za-z][A-Za-z0-9_]*$' readonly WIT_JIRA_CATEGORY_KEY_RE='^[A-Za-z][A-Za-z0-9-]*$' # Blocker issue keys go into the resolution lookup's `key in (...)` clause, so they get # the same allowlist treatment. -readonly WIT_JIRA_ISSUE_KEY_RE='^[A-Za-z][A-Za-z0-9_]*-[0-9]+$' +readonly WIT_JIRA_ISSUE_KEY_RE='^[A-Za-z][A-Za-z0-9_]*-[0-9]+\z' # config.jira.site becomes the request host that receives the Basic-auth token, so it # must be a BARE hostname — no scheme, path, userinfo (`@`), port, or control chars — @@ -417,7 +417,7 @@ readonly WIT_JIRA_NORMALIZE_PROGRAM=' def blockers: [(.fields.issuelinks // [])[] | select(.type.name == $blk and (.inwardIssue != null)) | .inwardIssue]; def done: (.fields.status.statusCategory.key) as $bk | ($dk | index($bk)) != null; - def lookup: .key as $k | ($res[$k] // {read: true, name: null}); + def lookup: .key as $k | ((if ($k | type) == "string" then $res[$k] else null end) // {read: true, name: null}); def wontdo: lookup as $l | $l.read and ($l.name | type) == "string" and ([$wd[] | ascii_downcase] | index($l.name | ascii_downcase)) != null; def unread: lookup | .read | not; From 9508ab9f59e49ff2659cfd0d3157c4f68c579b74 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:04:12 -0400 Subject: [PATCH 3/3] feat(work-items): classify won't-do blockers from each tracker's own vocabulary Drop the built-in Jira won't-do resolution names. config.jira.resolutions classifies resolution ids as completed or wont_do (team binding only, no default; absent turns detection off with no lookup). An unclassified id, a failed lookup, or a blocker missing from the search keeps blocking and warns on stderr, the missing case naming the keys. Add config.github.wont_do_labels: a closed blocker carrying a listed label counts as won't-do whatever its stateReason, matched case-insensitively. Setup lists Jira resolutions or repo labels and writes only the user's choice; check reports unclassified resolutions. attend-queue takes [intake] from triage buckets 1-3 and lists bucket 4 report-only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AXBazvq2HJYYKarzkTHrng --- ...t-work-items-wont-do-followups-79e9c670.md | 3 +- .../work-items/skills/attend-queue/SKILL.md | 12 ++- .../skills/attend-queue/evals/evals.json | 7 +- plugins/work-items/skills/setup/SKILL.md | 36 +++++++ .../setup/reference/autonomous-apply.md | 1 + .../skills/setup/reference/check.md | 7 ++ .../skills/setup/reference/providers.md | 14 ++- .../tools/work-item-tracker/CONTRACT.md | 72 +++++++++----- .../adapters/github/common.sh | 57 ++++++++--- .../adapters/github/common.test.sh | 40 ++++++++ .../adapters/github/list-items.sh | 1 + .../work-item-tracker/adapters/jira/README.md | 16 +-- .../work-item-tracker/adapters/jira/common.sh | 99 ++++++++++--------- .../adapters/jira/get-item.sh | 2 +- .../adapters/jira/get-item.test.sh | 97 ++++++++++-------- .../adapters/jira/list-items.sh | 2 +- .../adapters/jira/list-items.test.sh | 49 +++++++-- .../work-item-tracker/adapters/jira/mock.sh | 7 +- 18 files changed, 372 insertions(+), 150 deletions(-) diff --git a/.changes/work-items/feat-work-items-wont-do-followups-79e9c670.md b/.changes/work-items/feat-work-items-wont-do-followups-79e9c670.md index d62501901a..992336847c 100644 --- a/.changes/work-items/feat-work-items-wont-do-followups-79e9c670.md +++ b/.changes/work-items/feat-work-items-wont-do-followups-79e9c670.md @@ -4,4 +4,5 @@ bump: minor ### Added -- **Won't-do blockers.** The triage attention view has a fourth bucket, "blocked by won't-do": open items whose `blocked_by_wont_do_count` is above zero, whatever labels they carry, reported for the operator to drop the edge, re-scope, or close. The triage board accepts it as a `state`. The Jira adapter now reads done blockers' resolutions in one extra search and counts a blocker resolved as one of `config.jira.wont_do_resolutions` (team binding key, default `["Won't Do","Duplicate"]`, `[]` turns it off) in `blocked_by_count` and `blocked_by_wont_do_count`; a failed lookup keeps the done blocker blocking, as the GitHub adapter does. +- **Won't-do blockers.** The triage attention view has a fourth bucket, "blocked by won't-do": open items whose `blocked_by_wont_do_count` is above zero, whatever labels they carry, reported for the operator to drop the edge, re-scope, or close. The triage board accepts it as a `state`, and `/work-items:attend-queue` lists those items as report-only `[won't-do]` rows, taking `[intake]` from buckets 1-3 only. +- **Won't-do detection follows each tracker's own vocabulary, with no built-in list.** Jira: the optional team binding key `config.jira.resolutions` (`{"completed": [ids], "wont_do": [ids]}`) classifies resolution ids, so a renamed resolution keeps its class. Absent, detection is off and no lookup is made. Present, the adapter reads done blockers' resolutions in one extra search, counts a `wont_do` id as won't-do, and keeps a blocker with an unclassified id, a failed lookup, or a missing search result blocking, with a stderr warning. GitHub: the optional key `config.github.wont_do_labels` makes a closed blocker carrying a listed label (case-insensitive) won't-do whatever its close reason. `/work-items:setup apply` lists the Jira instance's resolutions or the repo's labels and writes the key only from the user's choice; `check` reports Jira resolutions the binding does not classify. diff --git a/plugins/work-items/skills/attend-queue/SKILL.md b/plugins/work-items/skills/attend-queue/SKILL.md index fb790b5405..0c79a5531a 100644 --- a/plugins/work-items/skills/attend-queue/SKILL.md +++ b/plugins/work-items/skills/attend-queue/SKILL.md @@ -65,8 +65,13 @@ Build a single merged view, oldest first, each row tagged by kind: 2. **`[ratify]`**, the subset of escalated items whose marker carries `kind=ratify-c3`: C3 bug-fix-shaped admissions the worker loop queued for first-drain ratification (earn-trust posture; see `/work-items:work-loop`'s admission gate). -3. **`[intake]`**. Untriaged raw intake, exactly the buckets `/work-items:triage`'s attention - view defines. Compose that view; do not re-derive its buckets here. +3. **`[intake]`**. Untriaged raw intake: buckets 1-3 of `/work-items:triage`'s attention view + (unlabeled, raw marker, needs-info with a reporter reply). Compose that view; do not re-derive + its buckets here. +4. **`[won't-do]`**, report-only. Bucket 4 of the same view (blocked by won't-do): items, often + already triaged, that wait on a blocker closed as won't-do. List them so the operator sees + them, but take no claim and make no change from this lane; the operator decides (drop the + edge, re-scope, or close) outside it. Lane-infrastructure items never enter the view, and this lane re-derives nothing to keep them out: the composed triage view already excludes the per-lane telemetry tracking issues (`/work-items:triage`, @@ -74,7 +79,8 @@ the composed triage view already excludes the per-lane telemetry tracking issues Present the merged table with one-line summaries, then work rows in the operator's chosen order (default: oldest first, `[ratify]` rows before `[escalated]` before `[intake]` at equal age, -ratifications unblock the waiting worker loop). +ratifications unblock the waiting worker loop). `[won't-do]` rows are listed after the worked +rows and are never worked or claimed here. ## Row claim (before any mutation) diff --git a/plugins/work-items/skills/attend-queue/evals/evals.json b/plugins/work-items/skills/attend-queue/evals/evals.json index f51b4f4caa..4f4bb264e0 100644 --- a/plugins/work-items/skills/attend-queue/evals/evals.json +++ b/plugins/work-items/skills/attend-queue/evals/evals.json @@ -4,15 +4,16 @@ { "id": 1, "name": "attend-queue-one-merged-view-escalations-ratify-intake", - "prompt": "/work-items:attend-queue\n\nThe repository has: two open items wearing the human-gated role label, one with a machine-marked work-items:escalation comment (a worker question) and one parked by the operator with no marker; one item whose escalation marker carries kind=ratify-c3; four untriaged items (two unlabeled, one with the raw marker, one status:needs-info where the reporter just replied). The binding remaps human-gated to blocked-on-human.", - "expected_output": "One merged attention view, oldest first, rows tagged [escalated], [ratify], and [intake]. The human-gated role resolves from config.role_labels to blocked-on-human and is never compared as the needs-human literal. Only the item with the machine-marked escalation comment appears as [escalated] — the parked item wearing the same label but no marker is excluded, because the marker (not the label) discriminates escalated from parked. The ratify-c3 item appears as [ratify]. The [intake] rows come from composing /work-items:triage's attention view (unlabeled, raw marker, needs-info-with-reply buckets), not from a re-derived bucket definition. Ratifications are worked first at equal age since they unblock the waiting worker loop.", + "prompt": "/work-items:attend-queue\n\nThe repository has: two open items wearing the human-gated role label, one with a machine-marked work-items:escalation comment (a worker question) and one parked by the operator with no marker; one item whose escalation marker carries kind=ratify-c3; four untriaged items (two unlabeled, one with the raw marker, one status:needs-info where the reporter just replied); and one already-triaged agent-ready item whose only blocker was closed as not planned, so triage's attention view lists it under blocked by won't-do. The binding remaps human-gated to blocked-on-human.", + "expected_output": "One merged attention view, oldest first, rows tagged [escalated], [ratify], [intake], and a report-only [won't-do]. The human-gated role resolves from config.role_labels to blocked-on-human and is never compared as the needs-human literal. Only the item with the machine-marked escalation comment appears as [escalated] — the parked item wearing the same label but no marker is excluded, because the marker (not the label) discriminates escalated from parked. The ratify-c3 item appears as [ratify]. The [intake] rows come from composing /work-items:triage's attention view, buckets 1-3 only (unlabeled, raw marker, needs-info-with-reply), not from a re-derived bucket definition. The item blocked by a won't-do blocker comes from that view's bucket 4 and is listed as a report-only [won't-do] row, never as [intake]: no claim is taken and nothing on it is changed from this lane. Ratifications are worked first at equal age since they unblock the waiting worker loop.", "files": [], "expectations": [ "Builds ONE merged view containing escalations, ratifications, and untriaged intake — not separate disconnected reports", "Resolves the human-gated role from config.role_labels (blocked-on-human) and never queries or compares the needs-human default literal after the remap", "Includes as [escalated] only items carrying BOTH the human-gated role label AND a machine-marked work-items:escalation comment; excludes the operator-parked item that has the label but no marker", "Tags the kind=ratify-c3 item as [ratify] and prioritizes ratify rows at equal age", - "Sources [intake] rows by composing /work-items:triage's attention view rather than re-deriving its buckets" + "Sources [intake] rows from buckets 1-3 of /work-items:triage's attention view rather than re-deriving its buckets", + "Lists the item blocked by won't-do as a report-only row, not [intake], and takes no claim or action on it" ] }, { diff --git a/plugins/work-items/skills/setup/SKILL.md b/plugins/work-items/skills/setup/SKILL.md index 2f66a1632e..1000dd61ae 100644 --- a/plugins/work-items/skills/setup/SKILL.md +++ b/plugins/work-items/skills/setup/SKILL.md @@ -181,6 +181,7 @@ unambiguous; ask only where an item genuinely needs the user. below, which writes `config.role_labels` into the binding just seeded. It is anchored here, after the bind, before any schedule work, so it runs identically on the skipped first-time path (where no interview happens) and on the seeding path, and every later step resolves the post-remap labels. + Then run the "Won't-do classification" procedure below for the bound provider. 3. **Migrate the work-class label axis.** Run the "Work-class label axis (migration)" procedure below. It discovers missing canonical members and provisions them when authorized. When any member is still missing after this pass, stop. Triage and the work-loop admission gate cannot operate correctly. @@ -353,6 +354,41 @@ Re-running `apply` once every role resolves as intended changes nothing **in thi "already configured" for the role labels. That says nothing about the rest of `apply`: a schedule still carrying zero rows re-offers seeding on every run, by design. +## Won't-do classification + +`apply` runs this right after the role-label pass, at **step 2**. A blocker closed as won't-do +keeps its dependent blocked and lands it in triage's "blocked by won't-do" bucket. How a tracker +marks won't-do differs per consumer, so the plugin ships no list: this pass reads the tracker's +own vocabulary and the user classifies it. Field semantics are CONTRACT.md "JSON output +contract" and "jira adapter". Skip it when `.work-item-tracker.json` is absent or the provider is +neither `github` nor `jira`. In both branches nothing is written without the user's choice; +with no interactive user, leave the key out (detection stays at its key-absent behavior) and +report INFO naming `/work-items:setup apply` with a user present. + +- **`github`: optional won't-do labels.** List the repo's labels (`gh label list --limit 200 + --json name,description`) and show them with the currently configured + `config.github.wont_do_labels`. Explain that a close as "not planned" or "duplicate" already + counts, and a listed label additionally counts on any close, for repos that mark won't-do with + a label such as `wontfix`. A suggestion from the names is fine; RECOMMENDED is to keep the + current value (none, when unset). On the user's pick, re-read the binding and merge only + `config.github.wont_do_labels`; an empty pick removes the key. +- **`jira`: classify resolutions.** Modeled on the role-label pass above: + 1. **Read the current binding** and present `config.jira.resolutions` when set. + 2. **Discover** the instance's resolutions with the paginated + `GET /rest/api/3/resolution/search` (the call, its auth and its fields are + [`reference/providers.md`](reference/providers.md) "`jira`"). Show each resolution's name and + description, and mark the default one. + 3. **Have the user sort each into `completed` or `wont_do`.** You may pre-select a suggestion + from the name (`Done` → completed; `Won't Do`, `Duplicate` → wont_do), + marked as a suggestion; the user confirms or changes every row. A resolution left unsorted + is unclassified: a done blocker carrying it keeps blocking and warns. + 4. **Write the binding**: re-read `.work-item-tracker.json` immediately before writing and merge + only `config.jira.resolutions` as `{"completed": [ids], "wont_do": [ids]}`, ids as strings. + It is team binding only; never write it to the personal overlay. + +On a re-run, a Jira binding that already classifies every listed resolution reports "already +configured"; otherwise offer only the resolutions it does not classify. + ## Output A tracked `.work-item-tracker.json` binding (provider + non-secret config) and a tracked diff --git a/plugins/work-items/skills/setup/reference/autonomous-apply.md b/plugins/work-items/skills/setup/reference/autonomous-apply.md index 081b57009a..dc8d2cf9f3 100644 --- a/plugins/work-items/skills/setup/reference/autonomous-apply.md +++ b/plugins/work-items/skills/setup/reference/autonomous-apply.md @@ -21,6 +21,7 @@ Applied to the three passes: | --- | --- | | Provider binding (`apply` step 1, which runs the "Provider binding" procedure) | **Binding already present and valid. Keep it, and re-bind nothing.** That is the procedure's own read-first RECOMMENDED answer, so this rule resolves to it silently: a repo bound to `local-markdown`, `jira`, or a consumer-local provider stays on it, and a working `gh` never switches it to `github`. Re-binding is a switch-providers decision, which no default can stand in for. (A present binding the probe already FAILs never reaches here. `apply` runs `check` first, and that probe FAILs a malformed shape, a provider resolving to no adapter, a missing required config key, and a `github` binding this checkout cannot derive a repo for.) **Binding absent**. Bind `github` with `config.lease_ttl_hours: 24`, both RECOMMENDED, **only when `gh` is installed AND `gh repo view --json owner,name` resolves in this checkout**. `gh repo view` is the adapter's own derivation and the operative test; `gh auth status` is not. [`providers.md`](providers.md) owns the rationale for why the account-level check is the wrong gate. Report the resolved `owner/repo` in the summary alongside the other defaults taken. Otherwise stop: `local-markdown` and `jira` need `storage_dir` / `config.jira` values that have no defaults and cannot be inferred, so there is no provider left to choose safely. Report "tracker binding needs a provider decision; run `/work-items:setup apply` with a user present". | | Role labels (step 2) | Keep the defaults, the RECOMMENDED answer, and the one that writes nothing. The pass runs and completes as a no-op: `config.role_labels` is left absent, so every role resolves to its documented fallback. A remap is a repo-vocabulary decision no default can stand in for. | +| Won't-do classification (step 2) | Write nothing: `config.github.wont_do_labels` and `config.jira.resolutions` stay as they are (absent keeps the key-absent behavior). Report INFO: "won't-do classification needs a user; run `/work-items:setup apply` with a user present". Which labels or resolutions mean won't-do is a tracker-vocabulary decision no default can stand in for. | | Work-class labels (step 3) | When any canonical member is missing: if the repo declares a label-as-code owner, stop, name the missing labels and point remediation at that owner. Otherwise stop: "work-class axis needs provisioning; run `/work-items:setup apply` with a user present". Never create labels ad hoc unattended. | | Capability-tier labels (step 4) | When `capability-tier: frontier` is missing: if the repo declares a label-as-code owner, stop. Name the missing label and point remediation at that owner. Otherwise stop: "capability-tier axis needs provisioning; run `/work-items:setup apply` with a user present". Never create labels ad hoc unattended. | | Legacy capability-tier backfill (step 5) | Unattended: run `backfill-capability-tier-labels.sh check` only and report candidates with the apply command for a user-present run. Never mutate item labels without confirmation. | diff --git a/plugins/work-items/skills/setup/reference/check.md b/plugins/work-items/skills/setup/reference/check.md index 55d6fc845c..d69fa42c18 100644 --- a/plugins/work-items/skills/setup/reference/check.md +++ b/plugins/work-items/skills/setup/reference/check.md @@ -111,3 +111,10 @@ check. `gh label create` ad hoc there). Providers without a label listing (`local-markdown`, read-only `jira`) → INFO: verify at triage time via the item store. When probe 2 is INFO (no binding) or FAIL (malformed binding), skip this probe. There is no addressable provider yet. +9. **Jira resolution classification**, only when probe 2 found a present, shape-valid `jira` + binding. List the instance's resolutions (the paginated call in + [`providers.md`](providers.md) "`jira`") and compare their ids with + `config.jira.resolutions`. Every check outcome here is INFO, never FAIL: key absent → won't-do + detection is off; ids on the instance that the binding classifies in neither list → name + each (a done blocker carrying one keeps blocking and warns); the call fails → say so with the + reason. Remediation is `/work-items:setup apply` with a user present. diff --git a/plugins/work-items/skills/setup/reference/providers.md b/plugins/work-items/skills/setup/reference/providers.md index 8906da7070..3ed3d8aede 100644 --- a/plugins/work-items/skills/setup/reference/providers.md +++ b/plugins/work-items/skills/setup/reference/providers.md @@ -52,6 +52,17 @@ Requires `config.jira` (`site`, non-empty `project_keys[]`, `auth_email`, `auth_ The API token is referenced by env-var name only, never stored. Binding shape and the deferred live-instance facts are CONTRACT.md's "jira adapter". +**Listing resolutions for the won't-do classification.** The instance's resolutions come from +`GET https:///rest/api/3/resolution/search`, paginated with `startAt` / `maxResults` until +`isLast` is true; each value carries `id`, `name`, `description`, and `default` (true on the +instance's default resolution). The older `GET /rest/api/3/resolution` is deprecated +([Issue resolutions API](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-resolutions/) +and its OpenAPI spec, as of 2026-10-10; recheck when that page changes the search endpoint +or its fields). Call it +with the same Basic auth the adapter uses (`auth_email` plus the token from `auth_env`), and +write ids, never names, into `config.jira.resolutions`: admins can rename a resolution, and an id +survives that. + ## `gitea` Gitea / Forgejo is self-hostable and free, so it is the no-paid-tool option for solo developers. @@ -108,7 +119,8 @@ than starting from a blank file. |---|---|---| | `lease_ttl_hours` | every provider | Claim-lease lifetime in hours. RECOMMENDED `24`. | | `storage_dir` | `local-markdown` | The item-store directory. | -| `jira` | `jira` | `site` (Cloud host), non-empty `project_keys[]`, `auth_email`, `auth_env` (env-var NAME holding the token). Optional `blocked_by_link_type` / `done_category_keys` override the deferred live-instance defaults; optional `wont_do_resolutions` (default `["Won't Do","Duplicate"]`) names the resolutions that make a done blocker won't-do. | +| `github` | optional, `github` | `wont_do_labels` (label names, no default, matched case-insensitively): a closed blocker carrying one counts as won't-do whatever its close reason. Written by the "Won't-do classification" pass. | +| `jira` | `jira` | `site` (Cloud host), non-empty `project_keys[]`, `auth_email`, `auth_env` (env-var NAME holding the token). Optional `blocked_by_link_type` / `done_category_keys` override the deferred live-instance defaults; optional `resolutions` (`{"completed":[ids],"wont_do":[ids]}`, no default, absent = won't-do detection off) classifies resolution ids, written by the "Won't-do classification" pass. | | `gitea` | `gitea` | `host` (bare hostname), non-empty `scopes[]` (each `owner/repo`, the declared read scope **and** the authorization boundary), `auth_env`. Optional `page_size` (default 50, which you lower if the instance sets `api.MAX_RESPONSE_ITEMS` below that), `host_suffix` (your own egress pin; Gitea is self-hosted, so there is no vendor-domain default), `allow_custom_domain`. | | `linear` | `linear` | `host` (`api.linear.app`), non-empty `scopes[]` (each `/`, all sharing one workspace), `auth_env`. Optional `done_state_types` (which `WorkflowState.type` values count as closed; default `completed`/`canceled`/`duplicate`), `page_size`, `host_suffix`, `allow_custom_domain`. | diff --git a/plugins/work-items/tools/work-item-tracker/CONTRACT.md b/plugins/work-items/tools/work-item-tracker/CONTRACT.md index 16c5b3e64d..498252b0fd 100644 --- a/plugins/work-items/tools/work-item-tracker/CONTRACT.md +++ b/plugins/work-items/tools/work-item-tracker/CONTRACT.md @@ -146,7 +146,8 @@ user-global layer. credential from the merged view), and the self-describing `docs` pointer. Everything else is shared coordination state and team-layer-only: `provider`, `config.role_labels`, `config.container_label`, `config.storage_dir`, - `config.jira.site`/`project_keys` and the JQL-shaping keys. An overlay value for any such key is a + `config.jira.site`/`project_keys`, the JQL-shaping keys, and the won't-do classifiers + `config.jira.resolutions` and `config.github.wont_do_labels`. An overlay value for any such key is a configuration error (exit `3`, naming the offending keys), never a merge, including a non-allowlisted key holding an empty object (only allowlisted-prefix scaffolding like `{"config":{}}` is inert). Allowlisted keys hold scalars: an object or array value at an @@ -330,19 +331,30 @@ Normalized item object: | Adapter | Resolved | Won't-do (blocks, counted) | |---|---|---| - | GitHub | `stateReason` `COMPLETED` or `null` | `NOT_PLANNED`, `DUPLICATE` | + | GitHub | `stateReason` `COMPLETED` or `null`, carrying no `wont_do_labels` label | `NOT_PLANNED`, `DUPLICATE`, or any close carrying a `wont_do_labels` label | | Linear | state type `completed`, while `done_state_types` lists it | other done types (`canceled`, `duplicate`) | | local-markdown | `closed`, no `state_reason` or `completed` | any other `state_reason` (`not_planned`, `duplicate`, ...) | - | Jira | done-category blocker whose resolution is not in `wont_do_resolutions` | resolution in `wont_do_resolutions` (default `Won't Do`, `Duplicate`) | + | Jira | done-category blocker with no resolution, a resolution id in `resolutions.completed`, or any resolution when `resolutions` is absent | resolution id in `resolutions.wont_do` | | Gitea | any closed blocker (won't-do detection unsupported) | none | + No adapter carries a built-in list of a consumer's won't-do markers: Jira resolutions + and GitHub labels are named per tracker, so the team binding classifies them + (`config.jira.resolutions`, `config.github.wont_do_labels`, both optional and + written by `/work-items:setup`). + GitHub: `blockedBy.totalCount` keeps counting closed blockers and the `gh --json - blockedBy` projection has no `stateReason`, so the adapter reads each closed blocker's - `stateReason` through `gh api graphql`. When that query fails, or returns no node for a - blocker, the closed blocker keeps blocking (fail closed) without counting as won't-do, - and a warning goes to stderr. A `null` reason is an issue closed before GitHub recorded - reasons. Jira reads done blockers' resolutions the same way and fails closed the same - way ("jira adapter" below). + blockedBy` projection has no `stateReason` or labels, so the adapter reads each closed + blocker's `stateReason` and labels through `gh api graphql`. The optional team binding + key `config.github.wont_do_labels` (an array of label names, no default, matched + case-insensitively; a present value must be an array of non-empty strings, exit `3`) + makes a closed blocker carrying any listed label won't-do whatever its `stateReason`, + for repos that mark won't-do with a label; absent, only the reasons above count. When + that query fails, or returns no node for a blocker, the closed blocker keeps blocking + (fail closed) without counting as won't-do, and a warning goes to stderr. A `null` + reason is an issue closed before GitHub recorded reasons. Jira reads done blockers' + resolutions with one extra search and fails closed the same way ("jira adapter" + below); it also keeps a done blocker blocking, without counting it as won't-do, when + its resolution id is in neither list. - `parent_id` is a fully-qualified ID or `null`. Bulk `list-items` rows MAY carry `parent_id: null` when the provider's list surface omits parent data (GitHub's does); `get-item` is authoritative for parent linkage. @@ -704,7 +716,7 @@ PR `SW2-*` linkage and the opt-in-write mechanism are sequenced follow-ups. "auth_env": "JIRA_API_TOKEN", "blocked_by_link_type": "Blocks", "done_category_keys": ["done", "completed"], - "wont_do_resolutions": ["Won't Do", "Duplicate"] + "resolutions": { "completed": ["10000"], "wont_do": ["10001", "10002"] } } } } @@ -724,28 +736,38 @@ PR `SW2-*` linkage and the opt-in-write mechanism are sequenced follow-ups. for two facts deferred to a live-instance pass: the authoritative blocker link type and the exact `statusCategory` key for the "Done" category (the official spec's own example disagrees with real instances, and both known keys are defaulted so the adapter is independent of that - deferred fact). `wont_do_resolutions` (default `["Won't Do", "Duplicate"]`, matched - case-insensitively; a present value must be an array of non-empty strings, exit `3`; `[]` - turns won't-do detection off) names the resolutions that mark a done blocker as won't-do. - Jira admins can rename and add resolutions + deferred fact). `resolutions` classifies the instance's resolutions by **id**: + `completed` lists the ids that resolve a done blocker, `wont_do` the ids that make it + won't-do. It has no default and is team-binding only: absent, won't-do detection is off, + no resolution lookup is made, and every done blocker is resolved. A present value must + be an object with exactly those two keys, each an array of non-empty string ids, with no + id in both (exit `3` otherwise). Ids, not names, because Jira admins can rename, add, + and delete resolutions ([add, edit, or delete resolutions](https://support.atlassian.com/jira-cloud-administration/docs/add-edit-or-delete-resolutions/), - as of 2026-10-10; recheck when an instance's resolution names change), so the names are a - team binding key, not plugin config. `Duplicate` is in the default because the GitHub - adapter counts a `DUPLICATE` close as won't-do. + as of 2026-10-10; recheck when that page changes how resolutions are identified), so no + name list fits every instance and a rename must not reclassify a resolution. + `/work-items:setup apply` writes the key from the instance's own resolution list. - **Read-path normalization** (CONTRACT.md "JSON output contract"): `state` is `closed` when the `statusCategory` key is in `done_category_keys`, else `open`; `assignees` is the single `assignee`'s `accountId` as a one-element array (empty when unassigned); `labels` is Jira `labels[]` verbatim (canonical role labels ride as ordinary labels; `list-frontier --autonomous` filters them core-side); `type` is the issue-type name; `blocked_by_count` counts **open** inward `blocked_by_link_type` links (the linked issue's status is inlined in `issuelinks`) plus - done ones whose resolution is in `wont_do_resolutions`, and `blocked_by_wont_do_count` - counts those won't-do ones. `issuelinks` carries no resolution, so after normalizing a - page (or one issue) the adapter reads every done blocker's resolution in one - `POST /search/jql` (`key in (...)`, `fields: ["resolution"]`, up to 100 keys per query), - skipped when there is no done blocker or the list is empty. Only blockers in - `project_keys` are looked up; a done blocker in another project stays resolved. When the - lookup fails, or does not return a blocker, that done blocker keeps blocking (fail closed) - without counting as won't-do, and a warning goes to stderr; `parent_id` comes from + done ones that are not resolved, and `blocked_by_wont_do_count` counts the done ones whose + resolution id is in `resolutions.wont_do`. `issuelinks` carries no resolution, so when + `resolutions` is present the adapter reads every done blocker's resolution (`id` and + `name`) in one `POST /search/jql` per page, or per issue for `get-item` (`key in (...)`, + `fields: ["resolution"]`, up to 100 keys per query), skipped when there is no done + blocker. Only blockers in `project_keys` are looked up; a done blocker in another project + stays resolved. A done blocker with no resolution, or a resolution id in `completed`, is + resolved. One whose id is in neither list (a resolution added after setup) keeps + blocking (fail closed), is not counted as won't-do, and a stderr warning names the + resolution and its id. When a lookup query fails, every blocker in that query keeps + blocking without counting as won't-do: Jira answers a `key in (...)` naming one key it + cannot resolve (a deleted issue, say) with a `400` for the whole query, so one bad key + fails its whole chunk of up to 100 closed, with no retry. A blocker the search did not + return keeps blocking the same way. Both warn on stderr, and the second names the + missing keys; `parent_id` comes from `fields.parent` (subtask→parent universally, story→epic where the instance uses the unified parent field rather than the legacy Epic-Link custom field, a documented best-effort limitation deferred with the sub-item link-type question); `url` is `https:///browse/`. diff --git a/plugins/work-items/tools/work-item-tracker/adapters/github/common.sh b/plugins/work-items/tools/work-item-tracker/adapters/github/common.sh index 908158d17f..844de521be 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/github/common.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/github/common.sh @@ -68,6 +68,7 @@ readonly WIT_GH_BOT readonly EX_INTERNAL=1 readonly EX_USAGE=2 +readonly EX_CONFIG=3 readonly EX_AUTH=4 readonly EX_NOT_FOUND=5 readonly EX_CONFLICT=7 @@ -251,9 +252,9 @@ readonly WIT_ITEM_JQ='{ labels: [(.labels // [])[] | .name], type: (.issueType.name // null), blocked_by_count: ([(.blockedBy.nodes // [])[] | select(.state != "CLOSED" or .reasonRead == false - or .stateReason == "NOT_PLANNED" or .stateReason == "DUPLICATE")] | length), + or .stateReason == "NOT_PLANNED" or .stateReason == "DUPLICATE" or .wontDoLabel == true)] | length), blocked_by_wont_do_count: ([(.blockedBy.nodes // [])[] | select(.state == "CLOSED" - and (.stateReason == "NOT_PLANNED" or .stateReason == "DUPLICATE"))] | length), + and (.stateReason == "NOT_PLANNED" or .stateReason == "DUPLICATE" or .wontDoLabel == true))] | length), parent_id: ( if (.parent // null) != null and (.parent.url // null) != null then (.parent.url @@ -264,15 +265,41 @@ readonly WIT_ITEM_JQ='{ url: .url }' +# wit_gh_load_wont_do_labels — set WIT_GH_WONT_DO_LABELS (JSON array, lowercased) from +# the binding's optional config.github.wont_do_labels; `[]` when the key or the binding +# is absent. A present value must be an array of non-empty strings, else exit 3. Call +# it before a pipeline into wit_gh_annotate_blocker_reasons, so the exit is not lost in +# a pipeline stage. +wit_gh_load_wont_do_labels() { + local binding ejson + WIT_GH_WONT_DO_LABELS='[]' + binding="$(wit_find_binding)" || return 0 + ejson="$(wit_effective_binding_json "$binding")" || { + echo "github: invalid binding at $binding; run /work-items:setup check" >&2 + exit "$EX_CONFIG" + } + WIT_GH_WONT_DO_LABELS="$(jq -c '(.config.github // {}) as $g + | if ($g | type) != "object" or ($g | has("wont_do_labels") | not) then [] + elif ($g.wont_do_labels | type) == "array" and all($g.wont_do_labels[]; type == "string" and length > 0) + then $g.wont_do_labels | map(ascii_downcase) + else "bad" end' <<<"$ejson")" + if [[ "$WIT_GH_WONT_DO_LABELS" == '"bad"' ]]; then + echo "github: config.github.wont_do_labels must be an array of non-empty label names" >&2 + exit "$EX_CONFIG" + fi +} + # wit_gh_annotate_blocker_reasons — stdin: one issue object or an array of them, as # `gh --json ...,blockedBy` returns it; stdout: the same JSON with `stateReason` set -# on every CLOSED blocker node. That projection carries no stateReason, so the -# reasons come from `gh api graphql` nodes(ids:), one query per 100 closed blockers +# on every CLOSED blocker node. That projection carries no stateReason or labels, so +# both come from `gh api graphql` nodes(ids:), one query per 100 closed blockers # and none when there is no closed blocker. Each CLOSED node also gets `reasonRead`: # false when its query failed, answered with a blank or unparsable body, or returned -# no node for it. A failed query is not -# fatal, but WIT_ITEM_JQ keeps an unread blocker blocking (fail closed). A node read -# with a null stateReason (issues closed before GitHub recorded reasons) is resolved. +# no node for it, and `wontDoLabel`: true when it was read and carries a label in +# WIT_GH_WONT_DO_LABELS (wit_gh_load_wont_do_labels; case-insensitive). A failed query +# is not fatal, but WIT_ITEM_JQ keeps an unread blocker blocking (fail closed). A node +# read with a null stateReason (issues closed before GitHub recorded reasons) and no +# listed label is resolved. wit_gh_annotate_blocker_reasons() { local json ids n i reasons='[]' out page args id json="$(cat)" @@ -285,7 +312,7 @@ wit_gh_annotate_blocker_reasons() { args+=(-f "ids[]=$id") done <<<"$(jq -r --argjson i "$i" '.[$i:$i + 100][]' <<<"$ids")" # shellcheck disable=SC2016 # GraphQL query — $ids is a GraphQL variable, not a bash expansion - if out="$(gh api graphql -f query='query($ids:[ID!]!){nodes(ids:$ids){... on Issue{id stateReason}}}' \ + if out="$(gh api graphql -f query='query($ids:[ID!]!){nodes(ids:$ids){... on Issue{id stateReason labels(first:100){nodes{name}}}}}' \ "${args[@]}" 2>/dev/null)" && page="$(jq -ce '[(.data.nodes // [])[] | select(. != null)]' <<<"$out" 2>/dev/null)"; then reasons="$(jq -cn --argjson acc "$reasons" --argjson page "$page" '$acc + $page')" @@ -293,18 +320,21 @@ wit_gh_annotate_blocker_reasons() { echo "wit_gh_annotate_blocker_reasons: close-reason query failed; closed blockers keep blocking" >&2 fi done - printf '%s' "$json" | jq -c --argjson r "$reasons" ' - (reduce $r[] as $x ({}; .[$x.id] = $x.stateReason)) as $by_id + printf '%s' "$json" | jq -c --argjson r "$reasons" --argjson wl "${WIT_GH_WONT_DO_LABELS:-[]}" ' + (reduce $r[] as $x ({}; .[$x.id] = $x)) as $by_id | def ann: if (.blockedBy.nodes // null) == null then . else .blockedBy.nodes |= map(if .state == "CLOSED" - then .id as $i | .stateReason = $by_id[$i] | .reasonRead = ($by_id | has($i)) else . end) end; + then .id as $i | .stateReason = $by_id[$i].stateReason | .reasonRead = ($by_id | has($i)) + | .wontDoLabel = any(($by_id[$i].labels.nodes // [])[]; (.name | ascii_downcase) as $n | any($wl[]; . == $n)) + else . end) end; if type == "array" then map(ann) else ann end' } # wit_emit_item — fetch the issue and emit the normalized # item object (CONTRACT.md "JSON output contract"). blocked_by_count counts OPEN -# blockers, blockers closed NOT_PLANNED or DUPLICATE, and closed blockers whose reason -# could not be read; blocked_by_wont_do_count counts the NOT_PLANNED and DUPLICATE ones. +# blockers, blockers closed NOT_PLANNED or DUPLICATE or carrying a config.github +# wont_do_labels label, and closed blockers whose reason could not be read; +# blocked_by_wont_do_count counts the won't-do ones. # gh >= 2.94 reads through `gh issue view --json` (issueType/blockedBy/parent). # Older gh reads REST, which sandboxed sessions serve where GraphQL 403s, and maps # the REST shape onto the same fields; type comes from .type, and parent and @@ -315,6 +345,7 @@ wit_emit_item() { if wit_gh_has_native_surface; then wit_run_gh read issue view "$number" -R "$owner/$repo" \ --json number,title,state,assignees,labels,issueType,blockedBy,parent,url + wit_gh_load_wont_do_labels printf '%s' "$WIT_GH_OUT" | wit_gh_annotate_blocker_reasons | jq -c --arg sv "$WIT_SCHEMA_VERSION" --arg or "$owner/$repo" "$WIT_ITEM_JQ" else diff --git a/plugins/work-items/tools/work-item-tracker/adapters/github/common.test.sh b/plugins/work-items/tools/work-item-tracker/adapters/github/common.test.sh index da438a8670..f5226428f8 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/github/common.test.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/github/common.test.sh @@ -152,6 +152,7 @@ node() { 4) printf '{"id":"I4","number":104,"state":"CLOSED"}' ;; 5) printf '{"id":"I5","number":105,"state":"CLOSED"}' ;; 6) printf '{"id":"I2","number":102,"state":"CLOSED"},{"id":"I6","number":106,"state":"CLOSED"}' ;; + 7) printf '{"id":"I7","number":107,"state":"CLOSED"}' ;; esac } issue() { @@ -176,6 +177,7 @@ elif [[ "$1 $2" == "api graphql" ]]; then "ids[]=I3") out+='{"id":"I3","stateReason":"NOT_PLANNED"},' ;; "ids[]=I4") out+='{"id":"I4","stateReason":"DUPLICATE"},' ;; "ids[]=I5") out+='{"id":"I5","stateReason":null},' ;; + "ids[]=I7") out+='{"id":"I7","stateReason":"COMPLETED","labels":{"nodes":[{"name":"bug"},{"name":"Wont-Fix"}]}},' ;; esac done printf '{"data":{"nodes":[%s]}}' "${out%,}" @@ -238,6 +240,44 @@ EOF "$(jq -r '[.items[].blocked_by_wont_do_count] | map(tostring) | join(" ")' <<<"$OUT")" assert_eq "list-items fetches every closed reason in one graphql query" "1" \ "$(grep -c 'api graphql' "$REASON_STUB/calls.log")" + assert_contains "the close-reason query fetches each closed blocker's labels" \ + "$(grep 'api graphql' "$REASON_STUB/calls.log")" "labels" + + # config.github.wont_do_labels: a closed blocker carrying a listed label is won't-do + # whatever its stateReason, matched case-insensitively. Fixture item 7 has one blocker + # closed COMPLETED and labeled Wont-Fix. + LABEL_DIR="$(mktemp -d "$TMP_ROOT/d.XXXXXX")" + gh_binding() { # : a github binding with that config.github + jq -cn --argjson g "$1" '{schema_version: "1.0", provider: "github", config: {lease_ttl_hours: 24, github: $g}}' \ + >"$LABEL_DIR/binding.json" + } + emitb() { + WORK_ITEM_TRACKER_BINDING="$LABEL_DIR/binding.json" GH_STUB_DIR="$REASON_STUB" PATH="$REASON_STUB:$PATH" \ + wit_emit_item o r "$1" 2>/dev/null + } + jq -cn '{schema_version: "1.0", provider: "github", config: {lease_ttl_hours: 24}}' >"$LABEL_DIR/binding.json" + OUT="$(emitb 7)" + assert_eq "no wont_do_labels key → a labeled COMPLETED close stays resolved" "0/0" \ + "$(jq -r '"\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" + gh_binding '{"wont_do_labels": ["wont-fix"]}' + OUT="$(emitb 7)" + assert_eq "a listed label on a COMPLETED close counts as won't-do, case-insensitively" "1/1" \ + "$(jq -r '"\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" + OUT="$(emitb 2)" + assert_eq "an unlabeled COMPLETED close stays resolved with wont_do_labels set" "0/0" \ + "$(jq -r '"\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" + OUT="$(emitb 3)" + assert_eq "NOT_PLANNED still counts with wont_do_labels set" "1/1" \ + "$(jq -r '"\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" + OUT="$(GH_STUB_GRAPHQL_FAIL=1 emitb 7)" + assert_eq "a failed query with wont_do_labels set keeps blocking, not won't-do" "1/0" \ + "$(jq -r '"\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" + for bad in '"wont-fix"' '[""]' '[1]' '{}'; do + gh_binding "{\"wont_do_labels\": $bad}" + (emitb 7 >/dev/null) + assert_eq "malformed wont_do_labels $bad → config (3)" "3" "$?" + done + rm -rf "$LABEL_DIR" rm -rf "$REASON_STUB" fi diff --git a/plugins/work-items/tools/work-item-tracker/adapters/github/list-items.sh b/plugins/work-items/tools/work-item-tracker/adapters/github/list-items.sh index b18d4b4356..3ffea48b77 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/github/list-items.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/github/list-items.sh @@ -36,6 +36,7 @@ limit="$(jq -r '.limits.list_items_max' "$WIT_GH_ADAPTER_DIR/capabilities.json") wit_run_gh read issue list -R "$target_repo" --state "$state" --limit "$limit" \ --json number,title,state,assignees,labels,issueType,blockedBy,url +wit_gh_load_wont_do_labels printf '%s' "$WIT_GH_OUT" | wit_gh_annotate_blocker_reasons | jq -c --arg sv "$WIT_SCHEMA_VERSION" --arg or "$target_repo" \ "{schema_version: \$sv, items: [.[] | $WIT_ITEM_JQ]}" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/README.md b/plugins/work-items/tools/work-item-tracker/adapters/jira/README.md index 8ddf042b85..1aa4a8d770 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/README.md +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/README.md @@ -46,13 +46,15 @@ error). `list-frontier --parent` is unavailable (needs `list-sub-items`, which i (CONTRACT.md "JSON output contract"): `state`, `assignees`, `labels`, `type`, `blocked_by_count`, `blocked_by_wont_do_count`, `parent_id`, `url`. `blocked_by_count` counts **open** inward blockers under the configured link type (`config.jira.blocked_by_link_type`, -default `Blocks`) plus done blockers resolved as won't-do, which `blocked_by_wont_do_count` -counts again. An issue link carries the blocker's status but not its resolution, so the adapter -reads done blockers' resolutions in one extra search per call (per page for `list-items`) and -matches them against `config.jira.wont_do_resolutions` (default `["Won't Do","Duplicate"]`, -case-insensitive; `[]` turns the lookup off). Set it in the team binding when your admins have -renamed or added resolutions. A failed lookup keeps the done blockers blocking; blockers outside -`project_keys` are not looked up and stay resolved. CONTRACT.md "jira adapter" has the rules. +default `Blocks`) plus done blockers that are not resolved; `blocked_by_wont_do_count` counts +the done ones resolved as won't-do. Won't-do detection is off until the team binding carries +`config.jira.resolutions`, which classifies this instance's resolution ids as `completed` or +`wont_do` (`/work-items:setup apply` writes it from the instance's resolution list; there is +no built-in list, since admins rename and add resolutions). With it set, the adapter reads done +blockers' resolutions in one extra search per call (per page for `list-items`). A done blocker +whose resolution id is in neither list, a failed lookup, or a blocker the lookup did not return +keeps blocking and warns on stderr; blockers outside `project_keys` are not looked up and stay +resolved. CONTRACT.md "jira adapter" has the rules. ## Auth & prerequisites diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh index 4676209ce7..9c3ed9267e 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/common.sh @@ -65,12 +65,6 @@ readonly WIT_JIRA_DEFAULT_BLOCKED_BY_LINK_TYPE="Blocks" # does not flow into an exported variable as shell-quoted text (would trip SC2089/2090). WIT_JIRA_DEFAULT_DONE_KEYS="$(jq -cn '["done","completed"]')" readonly WIT_JIRA_DEFAULT_DONE_KEYS -# Resolutions that mark a done blocker as won't-do, matched case-insensitively. Jira -# admins can rename or add resolutions, so the binding may override the list; an empty -# list turns won't-do detection off. "Duplicate" is in the default because the GitHub -# adapter counts a DUPLICATE close as won't-do. -WIT_JIRA_DEFAULT_WONT_DO_RESOLUTIONS="$(jq -cn '["Won\u0027t Do","Duplicate"]')" -readonly WIT_JIRA_DEFAULT_WONT_DO_RESOLUTIONS # Values interpolated into a JQL query (project keys, statusCategory keys) are # constrained to a strict allowlist BEFORE interpolation — the seam builds JQL by @@ -146,7 +140,7 @@ wit_require_jira_id() { # or a required key (site, non-empty project_keys, auth_email, auth_env) is missing. # Exports WIT_JIRA_SITE, WIT_JIRA_PROJECT_KEYS (JSON array), WIT_JIRA_AUTH_EMAIL, # WIT_JIRA_AUTH_ENV, WIT_JIRA_BLOCKED_BY_LINK_TYPE, WIT_JIRA_DONE_KEYS (JSON array), -# WIT_JIRA_WONT_DO_RESOLUTIONS (JSON array). +# WIT_JIRA_RESOLUTIONS (the config.jira.resolutions object, or `null` when absent). wit_need_jira_config() { local name binding ejson name="$(basename "${BASH_SOURCE[1]}")" @@ -177,12 +171,9 @@ wit_need_jira_config() { else WIT_JIRA_DONE_KEYS="$WIT_JIRA_DEFAULT_DONE_KEYS" fi - # Same presence read: a present value is judged below, never collapsed to the default. - if [[ "$(jq -r '.config.jira | has("wont_do_resolutions")' <<<"$ejson")" == "true" ]]; then - WIT_JIRA_WONT_DO_RESOLUTIONS="$(jq -c '.config.jira.wont_do_resolutions' <<<"$ejson")" - else - WIT_JIRA_WONT_DO_RESOLUTIONS="$WIT_JIRA_DEFAULT_WONT_DO_RESOLUTIONS" - fi + # resolutions has no default: absent turns won't-do detection off (`null`); a present + # value, even `null`, is judged below. + WIT_JIRA_RESOLUTIONS="$(jq -c '.config.jira | if has("resolutions") then .resolutions // "null-value" else null end' <<<"$ejson")" local allow_custom_domain allow_custom_domain="$(jq -r '.config.jira.allow_custom_domain // false' <<<"$ejson")" local missing="" @@ -228,10 +219,15 @@ wit_need_jira_config() { # already collapsed null into the default string). [[ "$(jq -r '(.config.jira.blocked_by_link_type) as $b | if $b == null then "default" elif (($b|type)=="string" and ($b|length)>0) then "ok" else "bad" end' <<<"$ejson")" != "bad" ]] || bad+=" blocked_by_link_type(must be a non-empty string)" - # wont_do_resolutions is matched in jq, never interpolated into JQL, so any non-empty - # string is a valid name; an empty array is the explicit opt-out. - [[ "$(jq -r 'if type == "array" and all(.[]; type == "string" and length > 0) then "ok" else "bad" end' <<<"$WIT_JIRA_WONT_DO_RESOLUTIONS")" == "ok" ]] || - bad+=" wont_do_resolutions(must be an array of non-empty strings)" + # resolutions classifies resolution ids, matched in jq and never interpolated into JQL: + # exactly the keys completed and wont_do, each an array of non-empty strings, with no + # id in both. + [[ "$(jq -r 'if . == null then "ok" + elif type == "object" and (keys == ["completed", "wont_do"]) + and all(.[]; type == "array" and all(.[]; type == "string" and length > 0)) + and ((.completed - (.completed - .wont_do)) == []) + then "ok" else "bad" end' <<<"$WIT_JIRA_RESOLUTIONS")" == "ok" ]] || + bad+=" resolutions(must be {\"completed\":[ids],\"wont_do\":[ids]}, non-empty string ids, none in both)" # Every element (each project key, each done key) is checked IN jq so an empty or # non-string element cannot slip through — a bash line-loop loses trailing empty # elements to command-substitution newline stripping, so `[""]` would evade it. @@ -255,7 +251,7 @@ wit_need_jira_config() { exit "$EX_CONFIG" } export WIT_JIRA_SITE WIT_JIRA_AUTH_EMAIL WIT_JIRA_AUTH_ENV WIT_JIRA_PROJECT_KEYS \ - WIT_JIRA_BLOCKED_BY_LINK_TYPE WIT_JIRA_DONE_KEYS WIT_JIRA_WONT_DO_RESOLUTIONS + WIT_JIRA_BLOCKED_BY_LINK_TYPE WIT_JIRA_DONE_KEYS WIT_JIRA_RESOLUTIONS } # wit_jira_project_in_scope — 0 when the key is in the binding's @@ -355,18 +351,21 @@ readonly WIT_JIRA_FIELDS="status,assignee,labels,issuetype,parent,issuelinks,sum export WIT_JIRA_FIELDS # wit_jira_blocker_resolutions — stdin: one raw Jira IssueBean or an array of them; -# stdout: a JSON object keyed by done inward-blocker key, each {read: true, name: -# } or {read: false}. An issue link carries the blocker's -# status but not its resolution, so the resolutions come from POST /search/jql with -# fields=resolution, one query per 100 keys and none when there is no done blocker or -# config.jira.wont_do_resolutions is empty. Only blockers in config.jira.project_keys -# are looked up (the declared read scope); others are left out and stay resolved. A -# failed query, or a key the search did not return, leaves {read: false}, which the -# normalizer keeps blocking (fail closed); a warning goes to stderr. +# stdout: a JSON object keyed by done inward-blocker key, each {class: "resolved" | +# "wont_do" | "blocking"}. An issue link carries the blocker's status but not its +# resolution, so the resolutions come from POST /search/jql with fields=resolution, +# one query per 100 keys. No query is made when config.jira.resolutions is absent +# (won't-do detection off, stdout `{}`) or there is no done blocker. Only blockers in +# config.jira.project_keys are looked up (the declared read scope); others are left out +# and stay resolved. The class comes from the resolution id: in wont_do → wont_do; in +# completed, or no resolution → resolved; any other id → blocking (fail closed), named +# on stderr. A failed query keeps every key in it blocking (a 400 on one bad key fails +# the whole chunk), and a key the search did not return keeps blocking; both warn on +# stderr. wit_jira_blocker_resolutions() { - local json keys n i chunk jql body out page res + local json keys n i chunk jql body out page res missing json="$(cat)" - if [[ "$(jq 'length' <<<"$WIT_JIRA_WONT_DO_RESOLUTIONS")" == "0" ]]; then + if [[ "$WIT_JIRA_RESOLUTIONS" == "null" ]]; then printf '{}' return 0 fi @@ -377,7 +376,7 @@ wit_jira_blocker_resolutions() { | select(.fields.status.statusCategory.key as $bk | ($dk | index($bk)) != null) | .key | select(type == "string" and test($re)) | select(split("-")[0] as $p | ($pk | index($p)) != null)] | unique' <<<"$json")" - res="$(jq -c 'map({key: ., value: {read: false}}) | from_entries' <<<"$keys")" + res="$(jq -c 'map({key: ., value: {class: "blocking"}}) | from_entries' <<<"$keys")" n="$(jq 'length' <<<"$keys")" for ((i = 0; i < n; i += 100)); do chunk="$(jq -c --argjson i "$i" '.[$i:$i + 100]' <<<"$keys")" @@ -386,30 +385,43 @@ wit_jira_blocker_resolutions() { '{jql: $jql, fields: ["resolution"], maxResults: $mr}')" if out="$(wit_jira_http POST "/search/jql" "$body" 2>/dev/null && [[ "$WIT_JIRA_STATUS" == 2* ]] && printf '%s' "$WIT_JIRA_BODY")" && - page="$(jq -ce 'if (.issues | type) == "array" - then [.issues[] | {key: .key, value: {read: true, name: (.fields.resolution.name // null)}}] | from_entries + page="$(jq -ce --argjson rs "$WIT_JIRA_RESOLUTIONS" --argjson chunk "$chunk" ' + if (.issues | type) == "array" then + [.issues[] | .key as $k | select(any($chunk[]; . == $k)) + | (.fields.resolution // null) as $r + | {key: $k, value: ( + if $r == null then {class: "resolved"} + elif any($rs.wont_do[]; . == ($r.id | tostring)) then {class: "wont_do"} + elif any($rs.completed[]; . == ($r.id | tostring)) then {class: "resolved"} + else {class: "blocking", unclassified: {id: ($r.id // null), name: ($r.name // null)}} end)}] + | from_entries else error("no issues array") end' <<<"$out" 2>/dev/null)"; then - res="$(jq -cn --argjson acc "$res" --argjson page "$page" \ - '$acc + ($page | with_entries(select(.key as $k | $acc | has($k))))')" + res="$(jq -cn --argjson acc "$res" --argjson page "$page" '$acc + $page')" + missing="$(jq -r --argjson page "$page" 'map(select(. as $k | $page | has($k) | not)) | join(", ")' <<<"$chunk")" + [[ -z "$missing" ]] || + echo "wit_jira_blocker_resolutions: the resolution query did not return $missing; they keep blocking" >&2 else - echo "wit_jira_blocker_resolutions: resolution query failed; done blockers keep blocking" >&2 + echo "wit_jira_blocker_resolutions: resolution query failed; its $(jq 'length' <<<"$chunk") done blocker(s) keep blocking" >&2 fi done + jq -r 'to_entries[] | select(.value.unclassified) | .value.unclassified as $u + | "wit_jira_blocker_resolutions: blocker \(.key) has resolution \($u.name) (id \($u.id)), which config.jira.resolutions does not classify; it keeps blocking. Classify it with /work-items:setup apply."' \ + <<<"$res" >&2 printf '%s' "$res" } # wit_jira_normalize_program — a jq program that maps one raw Jira IssueBean into the # seam's normalized item object (CONTRACT.md "JSON output contract"). Expects jq args # $sv (schema version), $site (Cloud host), $dk (JSON array of done statusCategory -# keys), $blk (blocker link-type name), $wd (config.jira.wont_do_resolutions), $res -# (wit_jira_blocker_resolutions output). blocked_by_count counts OPEN inward blockers, -# done blockers whose resolution is in $wd, and done blockers whose resolution lookup -# failed; blocked_by_wont_do_count counts the ones whose resolution is in $wd. +# keys), $blk (blocker link-type name), $res (wit_jira_blocker_resolutions output). +# blocked_by_count counts OPEN inward blockers plus done ones whose class is not +# resolved; blocked_by_wont_do_count counts the done ones classed wont_do. A done +# blocker missing from $res (detection off, or out of the declared scope) is resolved. # WIT_JIRA_NORMALIZE_PROGRAM is consumed by the sourcing verb scripts (get-item, # list-items), not within this file — SC2034 is a false positive on a sourced-only # lib. It is a jq program string, so it is not exported (an exported quoted program # trips SC2089/2090); the disable marks it intentionally external instead. -# shellcheck disable=SC2016,SC2034 # jq program — $sv/$site/$dk/$blk/$wd/$res are jq args, not bash; used by verb scripts +# shellcheck disable=SC2016,SC2034 # jq program — $sv/$site/$dk/$blk/$res are jq args, not bash; used by verb scripts readonly WIT_JIRA_NORMALIZE_PROGRAM=' def keyparts: (capture("^(?

[A-Za-z][A-Za-z0-9_]*)-(?[0-9]+)$") // error("jira: unparsable issue key: " + (. | tostring))); @@ -417,10 +429,7 @@ readonly WIT_JIRA_NORMALIZE_PROGRAM=' def blockers: [(.fields.issuelinks // [])[] | select(.type.name == $blk and (.inwardIssue != null)) | .inwardIssue]; def done: (.fields.status.statusCategory.key) as $bk | ($dk | index($bk)) != null; - def lookup: .key as $k | ((if ($k | type) == "string" then $res[$k] else null end) // {read: true, name: null}); - def wontdo: lookup as $l | $l.read and ($l.name | type) == "string" - and ([$wd[] | ascii_downcase] | index($l.name | ascii_downcase)) != null; - def unread: lookup | .read | not; + def class: .key as $k | ((if ($k | type) == "string" then $res[$k].class else null end) // "resolved"); { schema_version: $sv, id: (.key | qualify), @@ -430,8 +439,8 @@ readonly WIT_JIRA_NORMALIZE_PROGRAM=' assignees: [ .fields.assignee.accountId // empty ], labels: (.fields.labels // []), type: (.fields.issuetype.name // null), - blocked_by_count: ([blockers[] | select((done | not) or unread or wontdo)] | length), - blocked_by_wont_do_count: ([blockers[] | select(done and wontdo)] | length), + blocked_by_count: ([blockers[] | select((done | not) or class != "resolved")] | length), + blocked_by_wont_do_count: ([blockers[] | select(done and class == "wont_do")] | length), parent_id: (if (.fields.parent.key // null) == null then null else (.fields.parent.key | qualify) end), url: ("https://" + $site + "/browse/" + .key) }' diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.sh index 3242c87c1e..f1f08e15b6 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.sh @@ -52,7 +52,7 @@ res="$(wit_jira_blocker_resolutions <<<"$WIT_JIRA_BODY")" # provider returned an issue we cannot represent) with a clear message. jq -c --arg sv "$WIT_SCHEMA_VERSION" --arg site "$WIT_JIRA_SITE" \ --argjson dk "$WIT_JIRA_DONE_KEYS" --arg blk "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" \ - --argjson wd "$WIT_JIRA_WONT_DO_RESOLUTIONS" --argjson res "$res" \ + --argjson res "$res" \ "$WIT_JIRA_NORMALIZE_PROGRAM" <<<"$WIT_JIRA_BODY" || { printf 'get-item: %s response could not be normalized (malformed issue key?)\n' "$WIT_JIRA_STATUS" >&2 exit "$EX_UNAVAILABLE" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.test.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.test.sh index 453d1b9d11..3605af986b 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.test.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/get-item.test.sh @@ -21,17 +21,16 @@ jira_fixture_init trap 'rm -rf "$JIRA_FIX"' EXIT jira_write_binding '["SW2"]' -# A Jira issue with one OPEN blocker (SW2-9, new) and one CLOSED blocker (SW2-8, done) -# under the standard "Blocks" link type, plus a parent — exercises the OPEN-only -# blocked_by_count and done-exclusion, parent qualification, and state mapping. +# A Jira issue with one OPEN blocker (SW2-9, new) and one done blocker (SW2-8) under the +# standard "Blocks" link type, plus a parent: exercises the blocker counts, parent +# qualification, and state mapping. cat >"$JIRA_FIX/1.body" <<'JSON' {"key":"SW2-12345","fields":{"summary":"Do the thing","status":{"statusCategory":{"key":"indeterminate"}},"assignee":{"accountId":"acc-1"},"labels":["backend","urgent"],"issuetype":{"name":"Task"},"parent":{"key":"SW2-100"},"issuelinks":[{"type":{"name":"Blocks"},"inwardIssue":{"key":"SW2-9","fields":{"status":{"statusCategory":{"key":"new"}}}}},{"type":{"name":"Blocks"},"inwardIssue":{"key":"SW2-8","fields":{"status":{"statusCategory":{"key":"done"}}}}}]}} JSON printf '200' >"$JIRA_FIX/1.status" -# Call 2 is the resolution lookup for the done blocker SW2-8: resolved as Done. -printf '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"Done"}}}]}' >"$JIRA_FIX/2.body" -printf '200' >"$JIRA_FIX/2.status" +# With no config.jira.resolutions, won't-do detection is off: no resolution lookup is +# made and the done blocker is resolved. jira_run "$S" "jira:test.atlassian.net/SW2#12345" assert_eq "get-item happy path exit 0" "0" "$RC" assert_eq "schema_version" "1.0" "$(jq -r '.schema_version' <<<"$OUT")" @@ -41,61 +40,76 @@ assert_eq "state open (indeterminate)" "open" "$(jq -r '.state' <<<"$OUT")" assert_eq "assignee accountId" "acc-1" "$(jq -r '.assignees[0]' <<<"$OUT")" assert_eq "labels verbatim" "backend,urgent" "$(jq -r '.labels | join(",")' <<<"$OUT")" assert_eq "type name" "Task" "$(jq -r '.type' <<<"$OUT")" -assert_eq "blocked_by_count: a Done-resolved blocker is resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" -assert_eq "blocked_by_wont_do_count 0 for a Done resolution" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" assert_eq "parent_id qualified" "jira:test.atlassian.net/SW2#100" "$(jq -r '.parent_id' <<<"$OUT")" assert_eq "url is browse link" "https://test.atlassian.net/browse/SW2-12345" "$(jq -r '.url' <<<"$OUT")" case "$OUT" in *$'\r'*) fail "stdout CR-free" "no CR" "CR present" ;; *) pass "stdout CR-free" ;; esac -lookup="$(awk '/^--data$/{getline; print; exit}' "$JIRA_FIX/2.args")" -assert_eq "resolution lookup JQL names only the done blocker" 'key in ("SW2-8")' "$(jq -r '.jql' <<<"$lookup")" -assert_eq "resolution lookup asks for the resolution field" '["resolution"]' "$(jq -c '.fields' <<<"$lookup")" - -# --- won't-do: a done blocker whose resolution is in wont_do_resolutions --- -# with_lookup — run the happy-path issue with -# call 2 answered by the given body and status; sets OUT. +assert_eq "no resolutions key → no resolution lookup" "1" "$(cat "$JIRA_FIX/.counter")" +assert_eq "no resolutions key → the done blocker is resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "no resolutions key → won't-do count 0" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" + +# --- won't-do: config.jira.resolutions classifies done blockers by resolution id --- +# 10000 is completed and 10001 is won't-do; any other id is unclassified. +jira_write_binding '["SW2"]' '{"resolutions":{"completed":["10000"],"wont_do":["10001"]}}' +# with_lookup — run the issue above with call 2 (the +# resolution lookup) answered by the given body and status; sets OUT, ERR and RC. with_lookup() { printf '%s' "$1" >"$JIRA_FIX/2.body" printf '%s' "$2" >"$JIRA_FIX/2.status" jira_run "$S" "jira:test.atlassian.net/SW2#12345" } -with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"Won\u0027t Do"}}}]}' 200 -assert_eq "Won't Do blocker keeps blocking" "2" "$(jq -r '.blocked_by_count' <<<"$OUT")" -assert_eq "Won't Do blocker is counted as won't-do" "1" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" -with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"duplicate"}}}]}' 200 -assert_eq "Duplicate matches case-insensitively" "1" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"id":"10000","name":"Done"}}}]}' 200 +assert_eq "completed id → exit 0" "0" "$RC" +assert_eq "completed id → the done blocker is resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "completed id → won't-do count 0" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +lookup="$(awk '/^--data$/{getline; print; exit}' "$JIRA_FIX/2.args")" +assert_eq "resolution lookup JQL names only the done blocker" 'key in ("SW2-8")' "$(jq -r '.jql' <<<"$lookup")" +assert_eq "resolution lookup asks for the resolution field" '["resolution"]' "$(jq -c '.fields' <<<"$lookup")" + +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"id":"10001","name":"Will Not Do"}}}]}' 200 +assert_eq "wont_do id → the done blocker keeps blocking" "2" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "wont_do id → counted as won't-do" "1" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" + +# Classification is by id, never by name, so a renamed resolution keeps its class. +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"id":"10000","name":"Will Not Do"}}}]}' 200 +assert_eq "a completed id renamed Will Not Do is resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" + with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":null}}]}' 200 -assert_eq "a done blocker with no resolution is resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "null resolution → resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "null resolution → won't-do count 0" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" + +# An id the binding does not classify (one added after setup) fails closed and is named. +with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"id":"10099","name":"Abandoned"}}}]}' 200 +assert_eq "unclassified id → exit 0" "0" "$RC" +assert_eq "unclassified id → the done blocker keeps blocking" "2" "$(jq -r '.blocked_by_count' <<<"$OUT")" +assert_eq "unclassified id → not won't-do" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +assert_contains "unclassified id → stderr names the resolution" "$ERR" "Abandoned" +assert_contains "unclassified id → stderr names the id" "$ERR" "10099" # A failed lookup, or one that does not return the blocker, fails closed: the done -# blocker keeps blocking without counting as won't-do, and the item still emits. +# blocker keeps blocking without counting as won't-do, a warning goes to stderr, and +# the item still emits. with_lookup '{"errorMessages":["boom"]}' 500 assert_eq "failed lookup still exits 0" "0" "$RC" assert_eq "failed lookup keeps the done blocker blocking" "2" "$(jq -r '.blocked_by_count' <<<"$OUT")" assert_eq "failed lookup counts no won't-do" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +assert_contains "failed lookup warns on stderr" "$ERR" "resolution query failed" with_lookup '{"issues":[]}' 200 assert_eq "a blocker the lookup did not return keeps blocking" "2" "$(jq -r '.blocked_by_count' <<<"$OUT")" - -# A binding override names the instance's own resolutions; an empty list turns the -# lookup off, so the done blocker is resolved with no second request. -jira_write_binding '["SW2"]' '{"wont_do_resolutions":["Abandoned"]}' -with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"Abandoned"}}}]}' 200 -assert_eq "an overridden resolution name counts as won't-do" "1" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" -with_lookup '{"issues":[{"key":"SW2-8","fields":{"resolution":{"name":"Won\u0027t Do"}}}]}' 200 -assert_eq "the override replaces the default list" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" -jira_write_binding '["SW2"]' '{"wont_do_resolutions":[]}' -jira_run "$S" "jira:test.atlassian.net/SW2#12345" -assert_eq "empty wont_do_resolutions makes one request" "1" "$(cat "$JIRA_FIX/.counter")" -assert_eq "empty wont_do_resolutions leaves the done blocker resolved" "1" "$(jq -r '.blocked_by_count' <<<"$OUT")" -jira_write_binding '["SW2"]' '{"wont_do_resolutions":"Won\u0027t Do"}' -jira_run "$S" "jira:test.atlassian.net/SW2#12345" -assert_eq "non-array wont_do_resolutions → config (3)" "3" "$RC" -jira_write_binding '["SW2"]' '{"wont_do_resolutions":[""]}' -jira_run "$S" "jira:test.atlassian.net/SW2#12345" -assert_eq "empty-string wont_do_resolutions element → config (3)" "3" "$RC" +assert_eq "a blocker the lookup did not return is not won't-do" "0" "$(jq -r '.blocked_by_wont_do_count' <<<"$OUT")" +assert_contains "a blocker the lookup did not return is named on stderr" "$ERR" "SW2-8" + +# A malformed resolutions value is a config error (exit 3). +for bad in 'null' '"10001"' '["10001"]' '{"wont_do":["10001"]}' '{"completed":["10000"],"wont_do":[""]}' \ + '{"completed":["10000"],"wont_do":[10001]}' '{"completed":["10001"],"wont_do":["10001"]}' \ + '{"completed":[],"wont_do":[],"other":[]}'; do + jira_write_binding '["SW2"]' "{\"resolutions\":$bad}" + jira_run "$S" "jira:test.atlassian.net/SW2#12345" + assert_eq "malformed resolutions $bad → config (3)" "3" "$RC" +done # A done blocker outside project_keys is never looked up (the declared read scope) and # stays resolved. -jira_write_binding '["SW2"]' +jira_write_binding '["SW2"]' '{"resolutions":{"completed":["10000"],"wont_do":["10001"]}}' cat >"$JIRA_FIX/1.body" <<'JSON' {"key":"SW2-12346","fields":{"summary":"Cross-project","status":{"statusCategory":{"key":"new"}},"assignee":null,"labels":[],"issuetype":{"name":"Task"},"parent":null,"issuelinks":[{"type":{"name":"Blocks"},"inwardIssue":{"key":"OTHER-3","fields":{"status":{"statusCategory":{"key":"done"}}}}}]}} JSON @@ -103,6 +117,7 @@ jira_run "$S" "jira:test.atlassian.net/SW2#12346" assert_eq "out-of-scope done blocker makes no lookup" "1" "$(cat "$JIRA_FIX/.counter")" assert_eq "out-of-scope done blocker is resolved" "0" "$(jq -r '.blocked_by_count' <<<"$OUT")" rm -f "$JIRA_FIX/2.body" "$JIRA_FIX/2.status" +jira_write_binding '["SW2"]' # --- state mapping: a done-category issue normalizes to closed --- cat >"$JIRA_FIX/1.body" <<'JSON' diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh index cfaf1e8a32..38457bafdf 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.sh @@ -112,7 +112,7 @@ emitted=0 res="$(jq -c '.issues' <<<"$WIT_JIRA_BODY" | wit_jira_blocker_resolutions)" page_items="$(jq -c --arg sv "$WIT_SCHEMA_VERSION" --arg site "$WIT_JIRA_SITE" \ --argjson dk "$WIT_JIRA_DONE_KEYS" --arg blk "$WIT_JIRA_BLOCKED_BY_LINK_TYPE" \ - --argjson wd "$WIT_JIRA_WONT_DO_RESOLUTIONS" --argjson res "$res" \ + --argjson res "$res" \ ".issues[] | $WIT_JIRA_NORMALIZE_PROGRAM" <<<"$WIT_JIRA_BODY")" || { printf 'jira: list-items — an issue in a %s page could not be normalized (malformed issue key?)\n' "$WIT_JIRA_STATUS" >&2 exit "$EX_UNAVAILABLE" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.test.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.test.sh index 9d423b32eb..700dea4ea2 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.test.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/list-items.test.sh @@ -163,22 +163,59 @@ rc_for_jira "valid blocked_by_link_type accepted" "0" \ '{site:"test.atlassian.net", project_keys:["SW2"], auth_email:"a@b", auth_env:"JIRA_TEST_TOKEN", blocked_by_link_type:"Blocked By"}' # Won't-do blockers: one resolution lookup per page (call 2) covers every done blocker on -# it. ABC-7 was closed Won't Do and keeps blocking SW2-2; SW2-4 was closed Done. +# it. ABC-7 was resolved won't-do (id 10001) and keeps blocking SW2-2; SW2-4 was resolved +# completed (id 10000). +jira_write_binding '["SW2","ABC"]' '{"resolutions":{"completed":["10000"],"wont_do":["10001"]}}' cat >"$JIRA_FIX/1.body" <<'JSON' {"issues":[{"key":"SW2-2","fields":{"summary":"Dependent","status":{"statusCategory":{"key":"new"}},"assignee":null,"labels":[],"issuetype":{"name":"Task"},"parent":null,"issuelinks":[{"type":{"name":"Blocks"},"inwardIssue":{"key":"ABC-7","fields":{"status":{"statusCategory":{"key":"done"}}}}}]}},{"key":"SW2-3","fields":{"summary":"Unblocked","status":{"statusCategory":{"key":"new"}},"assignee":null,"labels":[],"issuetype":{"name":"Task"},"parent":null,"issuelinks":[{"type":{"name":"Blocks"},"inwardIssue":{"key":"SW2-4","fields":{"status":{"statusCategory":{"key":"done"}}}}}]}}],"nextPageToken":null,"isLast":true} JSON printf '200' >"$JIRA_FIX/1.status" -printf '{"issues":[{"key":"ABC-7","fields":{"resolution":{"name":"Won\u0027t Do"}}},{"key":"SW2-4","fields":{"resolution":{"name":"Done"}}}]}' >"$JIRA_FIX/2.body" +printf '{"issues":[{"key":"ABC-7","fields":{"resolution":{"id":"10001","name":"Will Not Do"}}},{"key":"SW2-4","fields":{"resolution":{"id":"10000","name":"Done"}}}]}' >"$JIRA_FIX/2.body" printf '200' >"$JIRA_FIX/2.status" jira_run "$S" --state open assert_eq "won't-do page exit 0" "0" "$RC" assert_eq "one search plus one resolution lookup" "2" "$(cat "$JIRA_FIX/.counter")" -assert_eq "lookup names both done blockers" 'key in ("ABC-7","SW2-4")' \ - "$(jq -r '.jql' <<<"$(awk '/^--data$/{getline; print; exit}' "$JIRA_FIX/2.args")")" -assert_eq "Won't Do blocker keeps its dependent blocked" "1/1" "$(jq -r '.items[0] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" -assert_eq "Done blocker resolves its dependent" "0/0" "$(jq -r '.items[1] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" +assert_eq "lookup names both done blockers" 'key in ("ABC-7","SW2-4")' "$(sent_jql 2)" +assert_eq "wont_do blocker keeps its dependent blocked" "1/1" "$(jq -r '.items[0] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" +assert_eq "completed blocker resolves its dependent" "0/0" "$(jq -r '.items[1] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" + +# A failed lookup keeps every done blocker on the page blocking, counts none as +# won't-do, warns on stderr, and still emits the page. +printf '500' >"$JIRA_FIX/2.status" +jira_run "$S" --state open +assert_eq "failed lookup → exit 0" "0" "$RC" +assert_eq "failed lookup → both dependents stay blocked, none won't-do" "1/0 1/0" \ + "$(jq -r '[.items[] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"] | join(" ")' <<<"$OUT")" +assert_contains "failed lookup → stderr warning" "$ERR" "resolution query failed" rm -f "$JIRA_FIX/2.body" "$JIRA_FIX/2.status" +# More than 100 done blockers split into lookups of at most 100 keys each. SW2-1000 is +# blocked by SW2-1..SW2-101, all done; every lookup answer resolves them as completed. +jq -cn '{issues: [{key: "SW2-1000", fields: {summary: "Many", status: {statusCategory: {key: "new"}}, + assignee: null, labels: [], issuetype: {name: "Task"}, parent: null, + issuelinks: [range(1; 102) | {type: {name: "Blocks"}, + inwardIssue: {key: "SW2-\(.)", fields: {status: {statusCategory: {key: "done"}}}}}]}}], + nextPageToken: null, isLast: true}' >"$JIRA_FIX/1.body" +printf '200' >"$JIRA_FIX/1.status" +jq -cn '{issues: [range(1; 102) | {key: "SW2-\(.)", fields: {resolution: {id: "10000", name: "Done"}}}]}' >"$JIRA_FIX/2.body" +cp "$JIRA_FIX/2.body" "$JIRA_FIX/3.body" +printf '200' >"$JIRA_FIX/2.status" +printf '200' >"$JIRA_FIX/3.status" +jira_run "$S" --state open +assert_eq "101 done blockers → one search plus two lookups" "3" "$(cat "$JIRA_FIX/.counter")" +assert_eq "first lookup carries 100 keys" "100" "$(sent_jql 2 | grep -o '"SW2-' | wc -l | tr -d ' ')" +assert_eq "second lookup carries the 101st key" "1" "$(sent_jql 3 | grep -o '"SW2-' | wc -l | tr -d ' ')" +assert_eq "all completed → resolved" "0/0" "$(jq -r '.items[0] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" +# A 400 on one chunk (Jira rejects the whole `key in (...)` query for one bad key) fails +# every key in that chunk closed, and only that chunk. +printf '400' >"$JIRA_FIX/3.status" +printf '{"errorMessages":["An issue with key SW2-99 does not exist"]}' >"$JIRA_FIX/3.body" +jira_run "$S" --state open +assert_eq "a 400 on the second chunk → exit 0" "0" "$RC" +assert_eq "a 400 on the second chunk keeps only its key blocking" "1/0" "$(jq -r '.items[0] | "\(.blocked_by_count)/\(.blocked_by_wont_do_count)"' <<<"$OUT")" +rm -f "$JIRA_FIX/2.body" "$JIRA_FIX/2.status" "$JIRA_FIX/3.body" "$JIRA_FIX/3.status" +jira_write_binding '["SW2","ABC"]' + # A single empty page (no token) terminates cleanly with an empty envelope. printf '{"issues":[],"nextPageToken":null,"isLast":true}' >"$JIRA_FIX/1.body" printf '200' >"$JIRA_FIX/1.status" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/mock.sh b/plugins/work-items/tools/work-item-tracker/adapters/jira/mock.sh index 0f2927643a..b2177d85a6 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/mock.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/mock.sh @@ -1,5 +1,5 @@ # shellcheck shell=bash -# shellcheck disable=SC2034 # JIRA_FIX/OUT/RC are read by the sourcing test files, not within this helper +# shellcheck disable=SC2034 # JIRA_FIX/OUT/ERR/RC are read by the sourcing test files, not within this helper # Offline fixture harness shared by this adapter's verb tests. Sourced, never run. # # Every verb path is exercised against a MOCK curl injected through WIT_JIRA_CURL, so no @@ -45,12 +45,13 @@ jira_write_binding() { } # jira_run