From 527650c3a112fd8468f1cf19e79b67bffb7aad5b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:47:21 -0500 Subject: [PATCH 1/2] fix(work-items): run the create-item sibling check in the linear seed create-item --parent in the linear seed now reads the parent's children with their completedAt and canceledAt first, and returns a same-titled child that is open or closed within 24 hours instead of filing a duplicate, as the github and local-markdown adapters already do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015te8Cr8u7uFJG4tdeUACLz --- ...near-create-item-sibling-check-7c41e2a9.md | 7 ++ .../tools/work-item-tracker/CONTRACT.md | 5 +- .../seeds/linear/create-item.sh | 48 +++++++++ .../seeds/linear/create-item.test.sh | 97 +++++++++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 .changes/work-items/fix-12918-linear-create-item-sibling-check-7c41e2a9.md diff --git a/.changes/work-items/fix-12918-linear-create-item-sibling-check-7c41e2a9.md b/.changes/work-items/fix-12918-linear-create-item-sibling-check-7c41e2a9.md new file mode 100644 index 00000000000..bd7136e3b54 --- /dev/null +++ b/.changes/work-items/fix-12918-linear-create-item-sibling-check-7c41e2a9.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- `create-item --parent` in the linear seed adapter no longer files a second child with a title the parent already has ([#12918](https://github.com/melodic-software/claude-code-plugins/issues/12918)): when a child with the same trimmed title is open, or was completed or canceled within 24 hours, it creates nothing, exits `0` and prints that child, as the GitHub and local-markdown adapters already do. diff --git a/plugins/work-items/tools/work-item-tracker/CONTRACT.md b/plugins/work-items/tools/work-item-tracker/CONTRACT.md index cb50fdbe04f..6a50de739b3 100644 --- a/plugins/work-items/tools/work-item-tracker/CONTRACT.md +++ b/plugins/work-items/tools/work-item-tracker/CONTRACT.md @@ -272,8 +272,9 @@ compare-and-set, so two calls that run at the same moment can still both create. parent's sub-issues in the target repository with their `closedAt` in one GraphQL query; local-markdown reads each child's `closed_at` frontmatter field, which only the file edit that closes the item sets (see "local-markdown adapter"), and a closed child without one counts as -closed earlier. The linear seed does not run the check yet; the gitea seed refuses -`--parent`. +closed earlier. The linear seed reads the parent's `children`, paged, with each child's +`completedAt` and `canceledAt`, and takes the later one that is set as the close time; a closed +child with neither counts as closed earlier. The gitea seed refuses `--parent`. `label-provenance` answers who applied each named label and whether that person may grant admission. For every `--label`, the adapter takes the most recent application of that label diff --git a/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.sh b/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.sh index f32581a5f01..9927f37bcba 100755 --- a/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.sh +++ b/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.sh @@ -101,6 +101,54 @@ if [[ -n "$BLOCKED_BY" ]]; then done fi +# Sibling dedupe (CONTRACT.md "Verbs"): a direct child of the parent with the same trimmed +# title, open or closed within 24 hours, is returned instead of a new issue. The close time +# is the later of Issue.completedAt and Issue.canceledAt (Linear's schema.graphql); a closed +# child with neither counts as closed earlier. An open match wins over a closed one. A failed +# read exits inside wit_linear_gql, so nothing is created on a read that did not complete. +if [[ -n "$PARENT_UUID" ]]; then + CHILD_QUERY='query($id: String!, $first: Int!, $after: String) { + issue(id: $id) { + children(first: $first, after: $after) { + nodes { '"$WIT_LINEAR_ISSUE_FIELDS"' completedAt canceledAt } + pageInfo { hasNextPage endCursor } + } + } + }' + CHILDREN='[]' + CHILD_CURSOR="" + while :; do + wit_linear_gql "$CHILD_QUERY" \ + "$(jq -cn --arg id "$PARENT_UUID" --argjson f "$WIT_LINEAR_PAGE_SIZE" --arg a "$CHILD_CURSOR" \ + '{id: $id, first: $f, after: (if ($a | length) > 0 then $a else null end)}')" \ + "listing children of $PARENT" + CHILD_PAGE="$(jq -c '.issue.children // {nodes: [], pageInfo: {hasNextPage: false}}' <<<"$WIT_LINEAR_DATA")" + CHILDREN="$(jq -c --argjson page "$CHILD_PAGE" '. + ($page.nodes // [])' <<<"$CHILDREN")" || { + printf 'create-item.sh: could not accumulate the children of %s\n' "$PARENT" >&2 + exit "$EX_INTERNAL" + } + [[ "$(jq -r '.pageInfo.hasNextPage // false' <<<"$CHILD_PAGE")" == "true" ]] || break + CHILD_CURSOR="$(jq -r '.pageInfo.endCursor // ""' <<<"$CHILD_PAGE")" + [[ -n "$CHILD_CURSOR" ]] || break + done + SIBLING="$(jq -c --arg title "$TITLE" --argjson dt "$WIT_LINEAR_DONE_TYPES" ' + def trimmed: gsub("^[[:space:]]+|[[:space:]]+$"; ""); + def is_open: (.state.type // "") as $s | ($dt | index($s)) == null; + def closed_at: [.completedAt, .canceledAt] | map(select(. != null)) | max; + ($title | trimmed) as $t + | [.[] | select(((.title // "") | trimmed) == $t) + | select(is_open or (closed_at != null + and ((closed_at | sub("\\.[0-9]+"; "") | fromdateiso8601) > now - 86400)))] + | (map(select(is_open)) + map(select(is_open | not)))[0] // empty' <<<"$CHILDREN")" || exit "$EX_INTERNAL" + if [[ -n "$SIBLING" ]]; then + SIBLING_ITEM="$(wit_linear_normalize "$SIBLING")" || exit "$EX_INTERNAL" + printf 'create-item.sh: %s already has %s child %s titled "%s"; created nothing\n' \ + "$PARENT" "$(jq -r '.state' <<<"$SIBLING_ITEM")" "$(jq -r '.id' <<<"$SIBLING_ITEM")" "$TITLE" >&2 + jq -c --arg p "$PARENT" '. + {parent_id: $p}' <<<"$SIBLING_ITEM" + exit 0 + fi +fi + # The team's UUID, and the label ids for any requested label names. Linear's # IssueCreateInput takes `labelIds`, not names — the same shape trap as Gitea's — so # names are resolved here and an unknown one is REFUSED rather than dropped: an item diff --git a/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.test.sh b/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.test.sh index 23b8c7da805..fe19c5e473b 100755 --- a/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.test.sh +++ b/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.test.sh @@ -24,6 +24,7 @@ TEAM_NODE='{"teams":{"nodes":[{"id":"uuid-team-eng"}]}}' # pagination/workspace-label cases below for why. LABEL_PAGE='{"issueLabels":{"nodes":[{"id":"uuid-label-fix","name":"type: fix"},{"id":"uuid-label-hi","name":"priority: high"}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}' +# seed_create []: the parent's children answer the sibling check, none by default. seed_create() { lin_reset lin_data 'issueRelationCreate' '{"issueRelationCreate":{"success":true}}' @@ -31,6 +32,24 @@ seed_create() { lin_data 'teams(filter:' "$TEAM_NODE" lin_data 'issueLabels(' "$LABEL_PAGE" lin_seed_issue 12 started + lin_data 'children(' "$(jq -cn --argjson n "${1:-[]}" \ + '{issue: {children: {nodes: $n, pageInfo: {hasNextPage: false, endCursor: null}}}}')" +} + +# child_node [<completedAt>]: a child as the sibling check selects it. +child_node() { + jq -c --arg c "${4:-}" '. + {completedAt: (if ($c | length) > 0 then $c else null end), canceledAt: null}' \ + <<<"$(lin_issue_json "$1" "$2" "$3")" +} +HOUR_AGO="$(jq -rn 'now - 3600 | todate')" +TWO_DAYS_AGO="$(jq -rn 'now - 172800 | todate')" + +assert_no_create() { + if [[ "$(lin_requests)" == *"issueCreate"* ]]; then + fail "$1" "no issueCreate" "issueCreate issued" + else + pass "$1" + fi } # --- happy path --- @@ -76,6 +95,84 @@ rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" assert_eq "create with a parent → exit 0" "0" "$rc" assert_contains "parentId is in the create input" "$(lin_bodies)" '"parentId":"uuid-issue-12"' +# --- a same-titled child of the parent is returned, not filed again (CONTRACT.md "Verbs") --- +# Open, with whitespace around the stored title: the match is exact after trimming both. +seed_create "[$(child_node 40 started ' child ')]" +rc="$(lin_run "$S" --title "child " --parent "linear:acme/ENG#12")" +assert_eq "open same-titled child → exit 0" "0" "$rc" +assert_no_create "and no issue is created for an open sibling" +assert_eq "stdout is the existing child" "linear:acme/ENG#40" "$(jq -r '.id' <<<"$(lin_out)")" +assert_eq "carrying the parent" "linear:acme/ENG#12" "$(jq -r '.parent_id' <<<"$(lin_out)")" +assert_eq "as an open item" "open" "$(jq -r '.state' <<<"$(lin_out)")" +assert_contains "stderr names the child" "$(lin_err)" "linear:acme/ENG#40" + +# Closed an hour ago by completedAt: still blocks. +seed_create "[$(child_node 41 completed child "$HOUR_AGO")]" +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "child completed 1h ago → exit 0" "0" "$rc" +assert_no_create "and no issue is created for a recently completed sibling" +assert_eq "stdout is the closed child" "linear:acme/ENG#41" "$(jq -r '.id' <<<"$(lin_out)")" + +# Canceled an hour ago: canceledAt is the close time of the canceled state type. +seed_create "[$(jq -c --arg c "$HOUR_AGO" '. + {completedAt: null, canceledAt: $c}' <<<"$(lin_issue_json 42 canceled child)")]" +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "child canceled 1h ago → exit 0" "0" "$rc" +assert_no_create "and no issue is created for a recently canceled sibling" +assert_eq "stdout is the canceled child" "linear:acme/ENG#42" "$(jq -r '.id' <<<"$(lin_out)")" + +# Completed two days ago, reopened, then canceled an hour ago: the later close time counts. +seed_create "[$(jq -c --arg d "$TWO_DAYS_AGO" --arg c "$HOUR_AGO" '. + {completedAt: $d, canceledAt: $c}' <<<"$(lin_issue_json 43 canceled child)")]" +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "child canceled 1h ago after an older completion → exit 0" "0" "$rc" +assert_no_create "and the older completion time does not let a duplicate through" + +# An open match wins over a recently closed one, whatever the order. +seed_create "[$(child_node 41 completed child "$HOUR_AGO"),$(child_node 40 started child)]" +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "open and closed siblings → exit 0" "0" "$rc" +assert_eq "the open sibling is returned" "linear:acme/ENG#40" "$(jq -r '.id' <<<"$(lin_out)")" + +# Closed two days ago: does not block. +seed_create "[$(child_node 41 completed child "$TWO_DAYS_AGO")]" +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "child completed 2 days ago → exit 0" "0" "$rc" +assert_contains "and a new issue is created" "$(lin_requests)" "issueCreate" +assert_eq "returning the new item" "linear:acme/ENG#12" "$(jq -r '.id' <<<"$(lin_out)")" + +# Closed with no readable close time counts as closed earlier. +seed_create "[$(child_node 41 completed child)]" +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "closed child with no close time → exit 0" "0" "$rc" +assert_contains "and a new issue is created over it" "$(lin_requests)" "issueCreate" + +# A differently titled open child does not block. +seed_create "[$(child_node 40 started 'other work')]" +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "differently titled child → exit 0" "0" "$rc" +assert_contains "and a new issue is created beside it" "$(lin_requests)" "issueCreate" + +# Without --parent nothing is read and the item is created as before. +seed_create "[$(child_node 40 started child)]" +rc="$(lin_run "$S" --title "child")" +assert_eq "same title without --parent → exit 0" "0" "$rc" +assert_contains "creates the item" "$(lin_requests)" "issueCreate" +assert_eq "returning the new item, not a sibling" "linear:acme/ENG#12" "$(jq -r '.id' <<<"$(lin_out)")" +if [[ "$(lin_requests)" == *"children("* ]]; then + fail "and reads no children" "no children query" "children query issued" +else + pass "and reads no children" +fi + +# A failed children read exits non-zero rather than creating a possible duplicate. +lin_reset +lin_data 'issueCreate' '{"issueCreate":{"success":true,"issue":{"id":"uuid-issue-12","number":12,"team":{"key":"ENG"}}}}' +lin_data 'teams(filter:' "$TEAM_NODE" +lin_seed_issue 12 started +lin_seed 'children(' 200 '{"errors":[{"message":"forbidden","extensions":{"type":"Forbidden"}}]}' +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "refused children read → exit 4" "4" "$rc" +assert_no_create "and nothing is created when the children read fails" + # --- ids are validated and RESOLVED before the item is created --- # The links are separate operations, so a bad id found afterwards would leave a created # item whose declared relationships were never written — an item that then looks ready. From 2e90319af6bda9ecfd6987ce5c67aa56f8d4e190 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:06:30 -0500 Subject: [PATCH 2/2] fix(work-items): keep linear children pages off jq argv and test paging The sibling-dedupe children walk passed each page to jq through --argjson. A page of full child nodes can exceed the per-argument exec limit, the exec fails, and the accumulation exits. Feed the accumulator and the page to jq on stdin with --slurp instead. Add a create-item case whose same-titled child sits on the second children page: it asserts no issueCreate, the page-2 child is returned, and the second request carries the endCursor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015te8Cr8u7uFJG4tdeUACLz --- .../seeds/linear/create-item.sh | 5 ++++- .../seeds/linear/create-item.test.sh | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.sh b/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.sh index 9927f37bcba..353de37730c 100755 --- a/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.sh +++ b/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.sh @@ -123,7 +123,10 @@ if [[ -n "$PARENT_UUID" ]]; then '{id: $id, first: $f, after: (if ($a | length) > 0 then $a else null end)}')" \ "listing children of $PARENT" CHILD_PAGE="$(jq -c '.issue.children // {nodes: [], pageInfo: {hasNextPage: false}}' <<<"$WIT_LINEAR_DATA")" - CHILDREN="$(jq -c --argjson page "$CHILD_PAGE" '. + ($page.nodes // [])' <<<"$CHILDREN")" || { + # Accumulator and page both on stdin. A page of full child nodes can pass the per-argument + # exec limit on its own, so neither travels in argv; a failed exec would otherwise drop + # children and let a duplicate through. + CHILDREN="$(printf '%s\n%s\n' "$CHILDREN" "$CHILD_PAGE" | jq -cs '.[0] + (.[1].nodes // [])')" || { printf 'create-item.sh: could not accumulate the children of %s\n' "$PARENT" >&2 exit "$EX_INTERNAL" } diff --git a/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.test.sh b/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.test.sh index fe19c5e473b..691be04d85f 100755 --- a/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.test.sh +++ b/plugins/work-items/tools/work-item-tracker/seeds/linear/create-item.test.sh @@ -151,6 +151,24 @@ rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" assert_eq "differently titled child → exit 0" "0" "$rc" assert_contains "and a new issue is created beside it" "$(lin_requests)" "issueCreate" +# A match on the second children page still blocks: the walk follows endCursor. Routes +# sharing a pattern are call-ordered, so the second children seeding is page 2. +CHILDREN_P1="$(jq -cn --argjson n "[$(child_node 40 started 'other work')]" \ + '{issue: {children: {nodes: $n, pageInfo: {hasNextPage: true, endCursor: "c1"}}}}')" +CHILDREN_P2="$(jq -cn --argjson n "[$(child_node 44 started child)]" \ + '{issue: {children: {nodes: $n, pageInfo: {hasNextPage: false, endCursor: null}}}}')" +lin_reset +lin_data 'issueCreate' '{"issueCreate":{"success":true,"issue":{"id":"uuid-issue-12","number":12,"team":{"key":"ENG"}}}}' +lin_data 'teams(filter:' "$TEAM_NODE" +lin_seed_issue 12 started +lin_data 'children(' "$CHILDREN_P1" +lin_data 'children(' "$CHILDREN_P2" +rc="$(lin_run "$S" --title "child" --parent "linear:acme/ENG#12")" +assert_eq "same-titled child on page 2 → exit 0" "0" "$rc" +assert_no_create "and no issue is created for a sibling on a later page" +assert_eq "stdout is the page-2 child" "linear:acme/ENG#44" "$(jq -r '.id' <<<"$(lin_out)")" +assert_contains "the second page was requested with the cursor" "$(lin_bodies)" '"after":"c1"' + # Without --parent nothing is read and the item is created as before. seed_create "[$(child_node 40 started child)]" rc="$(lin_run "$S" --title "child")"