diff --git a/.changes/work-items/feat-8324-get-field-options-3a9d4c71.md b/.changes/work-items/feat-8324-get-field-options-3a9d4c71.md new file mode 100644 index 0000000000..b60ce905e3 --- /dev/null +++ b/.changes/work-items/feat-8324-get-field-options-3a9d4c71.md @@ -0,0 +1,13 @@ +--- +bump: minor +--- + +### Added + +- The tracker seam's `get-field-options --field [--repo /]` verb reads + the options of one single-select issue field, so a caller such as planning's Work size can use a + tracker's own size values. The GitHub adapter implements it from the organization issue-field + listing `set-fields` validates against; a field the organization lacks prints an empty + `options` list and exits 0, and an adapter that declares no `get-field-options` key exits 6 + (unsupported). The other bundled adapters' manifests, the adapter generator's verb list and the + adapter-spec reference now declare the verb too (`false` outside GitHub). diff --git a/plugins/work-items/skills/onboard-adapter/reference/adapter-spec.md b/plugins/work-items/skills/onboard-adapter/reference/adapter-spec.md index 24a88bc9cb..e8e63540c4 100644 --- a/plugins/work-items/skills/onboard-adapter/reference/adapter-spec.md +++ b/plugins/work-items/skills/onboard-adapter/reference/adapter-spec.md @@ -29,7 +29,7 @@ A self-hosted, forge-shaped provider with no lease support: "create-item": true, "get-item": true, "claim": false, "renew-lease": false, "release": false, "reclaim": false, "link-blocks": true, "add-sub-item": false, "list-items": true, "list-sub-items": false, "label-provenance": false, - "set-fields": false, "change-link": true, "capabilities": true + "set-fields": false, "get-field-options": false, "change-link": true, "capabilities": true }, "features": { "cross_repo_edges": false, "sub_items": false, "leases": false, "labels": true @@ -75,12 +75,13 @@ rather than defaulted, because an unlisted verb means the spec was written again different contract revision, and guessing produces a manifest that lies. `create-item`, `get-item`, `claim`, `renew-lease`, `release`, `reclaim`, `link-blocks`, -`add-sub-item`, `list-items`, `list-sub-items`, `label-provenance`, `set-fields`, `change-link`, +`add-sub-item`, `list-items`, `list-sub-items`, `label-provenance`, `set-fields`, `get-field-options`, `change-link`, `capabilities`. `set-fields` sets single-select issue fields by option id (`CONTRACT.md` "Verbs"). Declare it `false` unless the provider has such fields and the adapter refuses an option id that is not one -of its field's options before writing. +of its field's options before writing. `get-field-options` is its read-only counterpart: declare it +`false` unless the adapter can list one field's options. `label-provenance` reports, for each label passed with `--label`, who applied it most recently, that person's permission on the repository, and whether the label is trusted diff --git a/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.sh b/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.sh index 4ef6b47488..ccaa58aab0 100755 --- a/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.sh +++ b/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.sh @@ -53,7 +53,7 @@ SHEBANG='#!/usr/bin/env bash' # minus list-frontier (core-derived), plus list-items. readonly ADAPTER_VERBS=( create-item get-item claim renew-lease release reclaim link-blocks - add-sub-item list-items list-sub-items label-provenance set-fields change-link capabilities + add-sub-item list-items list-sub-items label-provenance set-fields get-field-options change-link capabilities ) readonly FEATURE_KEYS=(cross_repo_edges sub_items leases labels) readonly LIMIT_KEYS=(sub_items_per_parent sub_item_depth dependencies_per_type list_items_max) diff --git a/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.test.sh b/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.test.sh index 914a053264..76d890ee4b 100755 --- a/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.test.sh +++ b/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.test.sh @@ -39,7 +39,7 @@ BASE_SPEC='{ "release": false, "reclaim": false, "link-blocks": false, "add-sub-item": false, "list-items": true, "list-sub-items": false, "label-provenance": false, - "set-fields": false, + "set-fields": false, "get-field-options": false, "change-link": true, "capabilities": true }, "features": { "cross_repo_edges": false, "sub_items": false, "leases": false, "labels": true }, diff --git a/plugins/work-items/tools/work-item-tracker/CONTRACT.md b/plugins/work-items/tools/work-item-tracker/CONTRACT.md index 62b227799b..6f5e7cba99 100644 --- a/plugins/work-items/tools/work-item-tracker/CONTRACT.md +++ b/plugins/work-items/tools/work-item-tracker/CONTRACT.md @@ -201,6 +201,7 @@ work-item-tracker.sh list-items [--state open|closed|all] [--repo /] [--la work-item-tracker.sh list-frontier [--autonomous] [--parent ] [--repo /] [--label ] work-item-tracker.sh label-provenance --label [--label ...] work-item-tracker.sh set-fields --field = [--field = ...] +work-item-tracker.sh get-field-options --field [--repo /] work-item-tracker.sh change-link ( | --branch-ref | --branch ) [--repo /] work-item-tracker.sh capabilities ``` @@ -243,6 +244,16 @@ option's name (pointer: 2026-10-18, recheck trigger: the write starts taking option ids, or the listing's permission changes). An adapter that declares no `set-fields` key exits `6`. +`get-field-options` is the read-only counterpart of `set-fields`: it lists the options of one +single-select issue field, so a caller such as planning's Work size can use a tracker's own size +values. `--field` is the field's exact name or its numeric id; `--repo` picks the repository whose +organization is read (default: the working directory's remote). It prints `{schema_version, field, +options: [{id, name}]}`. A field the organization does not have, or one with no options, prints an +empty `options` list and exits `0`; a caller that gets one falls back to its own defaults. Only the +GitHub adapter implements it, from the same `GET /orgs/{org}/issue-fields` listing `set-fields` +validates against (same `read:org` need). An adapter that declares no `get-field-options` key exits +`6`, which a caller reads as "unsupported", not as a failure. Label groups are not read by this verb. + `claim --ttl-minutes ` (0–59) **adds to** `--ttl-hours`, it never replaces it: `--ttl-hours 24 --ttl-minutes 30` is a 24.5-hour lease. For a sub-hour lease pass `--ttl-hours 0 --ttl-minutes `. diff --git a/plugins/work-items/tools/work-item-tracker/adapters/gitea/adapter-spec.json b/plugins/work-items/tools/work-item-tracker/adapters/gitea/adapter-spec.json index 2741fee1a2..cddd8352bb 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/gitea/adapter-spec.json +++ b/plugins/work-items/tools/work-item-tracker/adapters/gitea/adapter-spec.json @@ -25,6 +25,7 @@ "list-items": true, "label-provenance": false, "set-fields": false, + "get-field-options": false, "list-sub-items": false, "capabilities": true, "change-link": true diff --git a/plugins/work-items/tools/work-item-tracker/adapters/gitea/capabilities.json b/plugins/work-items/tools/work-item-tracker/adapters/gitea/capabilities.json index dd7b66585d..67938841a5 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/gitea/capabilities.json +++ b/plugins/work-items/tools/work-item-tracker/adapters/gitea/capabilities.json @@ -28,6 +28,7 @@ "list-items": true, "label-provenance": false, "set-fields": false, + "get-field-options": false, "list-sub-items": false, "reclaim": false, "release": false, diff --git a/plugins/work-items/tools/work-item-tracker/adapters/github/capabilities.json b/plugins/work-items/tools/work-item-tracker/adapters/github/capabilities.json index fa86b10968..72b97fce49 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/github/capabilities.json +++ b/plugins/work-items/tools/work-item-tracker/adapters/github/capabilities.json @@ -14,6 +14,7 @@ "list-sub-items": true, "label-provenance": true, "set-fields": true, + "get-field-options": true, "capabilities": true, "change-link": true }, diff --git a/plugins/work-items/tools/work-item-tracker/adapters/github/get-field-options.sh b/plugins/work-items/tools/work-item-tracker/adapters/github/get-field-options.sh new file mode 100755 index 0000000000..3bb8b8e175 --- /dev/null +++ b/plugins/work-items/tools/work-item-tracker/adapters/github/get-field-options.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# get-field-options --field [--repo /] — CONTRACT.md "Verbs (core public +# surface)". Read-only. Lists the options of one single-select organization issue field, from the +# same listing set-fields validates against (GET orgs/{org}/issue-fields). The field is matched by +# its exact name or its numeric id. A field the organization does not have, or one with no options, +# yields an empty list and exit 0, so a caller falls back to its own defaults. +set -uo pipefail +# shellcheck source=common.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +usage="usage: get-field-options --field [--repo /]" +wit_help_if_requested "$usage" "$@" + +field="" repo_override="" +while [[ $# -gt 0 ]]; do + case "$1" in + --field) + [[ $# -ge 2 && -n "$2" && -z "$field" ]] || wit_usage_error "--field needs one non-empty value" + field="$2" + shift 2 + ;; + --repo) + [[ $# -ge 2 && -n "$2" ]] || wit_usage_error "--repo needs a value" + repo_override="$2" + shift 2 + ;; + *) wit_usage_error "unknown argument: $1" ;; + esac +done +[[ -n "$field" ]] || wit_usage_error "$usage" +[[ -z "$repo_override" || "$repo_override" =~ ^[^/]+/[^/]+$ ]] || + wit_usage_error "--repo must be / (got: $repo_override)" + +# $() swallows wit_run_gh's exit-on-error; propagate its code (see create-item.sh). +target_repo="$(wit_resolve_repo "$repo_override")" || exit "$?" +owner="${target_repo%%/*}" + +wit_run_gh read api -H "X-GitHub-Api-Version: 2026-03-10" "orgs/$owner/issue-fields" +options="$(jq -c --arg f "$field" '[.[] | select((.id | tostring) == $f or .name == $f) + | .options // [] | .[] | {id, name}]' <<<"$WIT_GH_OUT")" || { + echo "get-field-options: org issue-field listing for $owner is not JSON" >&2 + exit "$EX_INTERNAL" +} + +jq -nc --arg sv "$WIT_SCHEMA_VERSION" --arg field "$field" --argjson options "$options" \ + '{schema_version: $sv, field: $field, options: $options}' diff --git a/plugins/work-items/tools/work-item-tracker/adapters/github/get-field-options.test.sh b/plugins/work-items/tools/work-item-tracker/adapters/github/get-field-options.test.sh new file mode 100755 index 0000000000..b250e17000 --- /dev/null +++ b/plugins/work-items/tools/work-item-tracker/adapters/github/get-field-options.test.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2154 # FAILED/CASE_NUM initialized by the sourced helper +# Offline: get-field-options against a gh stub. The org field listing is the trimmed recording +# set-fields.test.sh uses (GET orgs/melodic-software/issue-fields, 2026-10-18). +set -uo pipefail +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT +S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/get-field-options.sh" +source "$(dirname "$S")/../../lib/verb-test-helpers.sh" + +assert_help "$S" +assert_usage_error "$S" # no --field +assert_usage_error "$S" --field # no value +assert_usage_error "$S" --field Size --field Complexity # one field only +assert_usage_error "$S" --field Size --repo notarepo # malformed --repo +assert_usage_error "$S" --field Size --bogus + +command -v jq >/dev/null 2>&1 || skip_suite "jq not on PATH" + +STUB="$TMP_ROOT/bin" +LOG="$TMP_ROOT/gh.log" +mkdir -p "$STUB" +cat >"$STUB/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$GH_STUB_LOG" +for a in "$@"; do + case "$a" in + orgs/*/issue-fields) + [[ -z "${GH_STUB_ERR:-}" ]] || { printf '%s\n' "$GH_STUB_ERR" >&2; exit 1; } + cat <<'JSON' +[{"data_type":"single_select","id":15462270,"name":"Size","options":[{"id":27053566,"name":"Large"},{"id":27053567,"name":"Medium"},{"id":27053568,"name":"Small"}]}, + {"data_type":"single_select","id":48625720,"name":"Complexity","options":[{"id":85127653,"name":"High"},{"id":85127651,"name":"Low"}]}, + {"data_type":"date","id":7,"name":"Target date","options":null}] +JSON + exit 0 + ;; + esac +done +printf 'gh-stub: unhandled %s\n' "$*" >&2 +exit 90 +EOF +chmod +x "$STUB/gh" + +run() { + : >"$LOG" + OUT="$(PATH="$STUB:$PATH" GH_STUB_LOG="$LOG" bash "$S" "$@" 2>"$TMP_ROOT/err")" + RC=$? + ERR="$(<"$TMP_ROOT/err")" +} + +run --field Size --repo o/r +assert_eq "named field → exit 0" "0" "$RC" +assert_eq "lists the field's options" '[{"id":27053566,"name":"Large"},{"id":27053567,"name":"Medium"},{"id":27053568,"name":"Small"}]' \ + "$(jq -c '.options' <<<"$OUT")" +assert_eq "output echoes the requested field" "Size" "$(jq -r '.field' <<<"$OUT")" +assert_contains "reads the repo owner's org" "$(head -n 1 "$LOG")" "orgs/o/issue-fields" + +run --field 48625720 --repo o/r +assert_eq "field by numeric id → exit 0" "0" "$RC" +assert_eq "field by id lists its options" '["High","Low"]' "$(jq -c '[.options[].name]' <<<"$OUT")" + +run --field Missing --repo o/r +assert_eq "absent field → exit 0" "0" "$RC" +assert_eq "absent field → empty list" "[]" "$(jq -c '.options' <<<"$OUT")" + +run --field "Target date" --repo o/r +assert_eq "field with no options → exit 0" "0" "$RC" +assert_eq "field with no options → empty list" "[]" "$(jq -c '.options' <<<"$OUT")" + +GH_STUB_ERR="HTTP 403: Forbidden" run --field Size --repo o/r +assert_eq "failed listing → mapped auth exit" "4" "$RC" +assert_contains "failed listing → gh error reaches stderr" "$ERR" "HTTP 403" + +[[ $FAILED -eq 0 ]] || exit 1 diff --git a/plugins/work-items/tools/work-item-tracker/adapters/jira/capabilities.json b/plugins/work-items/tools/work-item-tracker/adapters/jira/capabilities.json index 4a06b00bd2..7392684181 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/jira/capabilities.json +++ b/plugins/work-items/tools/work-item-tracker/adapters/jira/capabilities.json @@ -13,6 +13,7 @@ "list-items": true, "label-provenance": false, "set-fields": false, + "get-field-options": false, "list-sub-items": false, "capabilities": true, "change-link": true diff --git a/plugins/work-items/tools/work-item-tracker/adapters/linear/adapter-spec.json b/plugins/work-items/tools/work-item-tracker/adapters/linear/adapter-spec.json index 0b67053acb..30b25b5258 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/linear/adapter-spec.json +++ b/plugins/work-items/tools/work-item-tracker/adapters/linear/adapter-spec.json @@ -25,6 +25,7 @@ "list-items": true, "label-provenance": false, "set-fields": false, + "get-field-options": false, "list-sub-items": true, "capabilities": true, "change-link": true diff --git a/plugins/work-items/tools/work-item-tracker/adapters/linear/capabilities.json b/plugins/work-items/tools/work-item-tracker/adapters/linear/capabilities.json index c1ef79c079..f2876e1226 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/linear/capabilities.json +++ b/plugins/work-items/tools/work-item-tracker/adapters/linear/capabilities.json @@ -28,6 +28,7 @@ "list-items": true, "label-provenance": false, "set-fields": false, + "get-field-options": false, "list-sub-items": true, "reclaim": true, "release": false, diff --git a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/capabilities.json b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/capabilities.json index f0b85c2b31..27f780ad49 100644 --- a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/capabilities.json +++ b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/capabilities.json @@ -13,6 +13,7 @@ "list-items": true, "label-provenance": false, "set-fields": false, + "get-field-options": false, "list-sub-items": true, "capabilities": true, "change-link": true diff --git a/plugins/work-items/tools/work-item-tracker/conformance/run-conformance.sh b/plugins/work-items/tools/work-item-tracker/conformance/run-conformance.sh index 6cdfddbb0f..04e5e4b739 100755 --- a/plugins/work-items/tools/work-item-tracker/conformance/run-conformance.sh +++ b/plugins/work-items/tools/work-item-tracker/conformance/run-conformance.sh @@ -204,6 +204,7 @@ wit_case_if_unsupported link-blocks "$FAKE_ID" --blocked-by "$FAKE_ID" wit_case_if_unsupported add-sub-item "$FAKE_ID" --parent "$FAKE_ID" wit_case_if_unsupported label-provenance "$FAKE_ID" --label agent-ready wit_case_if_unsupported set-fields "$FAKE_ID" --field 1=2 +wit_case_if_unsupported get-field-options --field Size if ! verb_supported list-items; then wit_case "unsupported list-items gates list-frontier → exit 6" 6 list-frontier fi diff --git a/plugins/work-items/tools/work-item-tracker/work-item-tracker.sh b/plugins/work-items/tools/work-item-tracker/work-item-tracker.sh index 9d5cb1a196..c427d238a7 100755 --- a/plugins/work-items/tools/work-item-tracker/work-item-tracker.sh +++ b/plugins/work-items/tools/work-item-tracker/work-item-tracker.sh @@ -56,6 +56,7 @@ Verbs: list-frontier [--autonomous] [--parent ] [--repo /] [--label ] label-provenance --label [--label ...] set-fields --field = [--field = ...] + get-field-options --field [--repo /] change-link ( | --branch-ref | --branch ) [--repo /] capabilities Contract: tools/work-item-tracker/CONTRACT.md @@ -128,7 +129,7 @@ main() { local adapter_verb="$verb" case "$verb" in - create-item | get-item | claim | renew-lease | release | reclaim | link-blocks | add-sub-item | list-sub-items | label-provenance | set-fields | change-link | capabilities) ;; + create-item | get-item | claim | renew-lease | release | reclaim | link-blocks | add-sub-item | list-sub-items | label-provenance | set-fields | get-field-options | change-link | capabilities) ;; list-items) ;; list-frontier) # Chosen BEFORE the capability gate, so an adapter without list-sub-items diff --git a/plugins/work-items/tools/work-item-tracker/work-item-tracker.test.sh b/plugins/work-items/tools/work-item-tracker/work-item-tracker.test.sh index 2df07f2676..ed18b73ab6 100755 --- a/plugins/work-items/tools/work-item-tracker/work-item-tracker.test.sh +++ b/plugins/work-items/tools/work-item-tracker/work-item-tracker.test.sh @@ -170,6 +170,12 @@ RC_CAPTURE=$? assert_eq "undeclared set-fields verb → exit 6" "6" "$RC_CAPTURE" assert_contains "exit-6 stderr names the set-fields verb" "$ERR" "set-fields" +# An adapter with no issue fields declares no get-field-options key: the verb reports unsupported. +ERR="$(run_dispatcher get-field-options --field Size 2>&1 >/dev/null)" +RC_CAPTURE=$? +assert_eq "undeclared get-field-options verb → exit 6" "6" "$RC_CAPTURE" +assert_contains "exit-6 stderr names the get-field-options verb" "$ERR" "get-field-options" + # --- capabilities passthrough --- OUT="$(run_dispatcher capabilities)"