Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions plugins/work-items/tools/work-item-tracker/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,57 @@ 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")"
# 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"
}
[[ "$(jq -r '.pageInfo.hasNextPage // false' <<<"$CHILD_PAGE")" == "true" ]] || break
CHILD_CURSOR="$(jq -r '.pageInfo.endCursor // ""' <<<"$CHILD_PAGE")"
[[ -n "$CHILD_CURSOR" ]] || break
done
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,32 @@ 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 [<children-nodes-json>]: the parent's children answer the sibling check, none by default.
seed_create() {
lin_reset
lin_data 'issueRelationCreate' '{"issueRelationCreate":{"success":true}}'
lin_data 'issueCreate' '{"issueCreate":{"success":true,"issue":{"id":"uuid-issue-12","number":12,"team":{"key":"ENG"}}}}'
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 <number> <state-type> <title> [<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 ---
Expand Down Expand Up @@ -76,6 +95,102 @@ 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"

# 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")"
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.
Expand Down
Loading