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 GitHub and local-markdown adapters 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 closed within 24 hours, it creates nothing, exits `0` and prints that child, so a retried or repeated publish files each child once.
21 changes: 20 additions & 1 deletion plugins/work-items/tools/work-item-tracker/CONTRACT.md
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,19 @@ with one link can both write. An adapter that declares no `add-recurrence` key e
command substitution, a shape a worktree-isolated session refuses (#6929). `--body` together with
`--body-file`, or a path that is not a readable, non-empty file, exits `2`.

`create-item --parent <id>` creates nothing when the parent already has a child with the same
title (exact after trimming leading and trailing whitespace from both) that is open, or
was closed within the last 24 hours. It then exits `0`, prints that child's normalized item
object, and names the child on stderr. A child closed earlier does not block a new create. This
makes a retried or repeated publish of one plan file each child once. Without `--parent`, every
call creates an item. The check is one read of the parent's children before the write, with no
compare-and-set, so two calls that run at the same moment can still both create. GitHub reads the
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`.

`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
(GitHub: the latest `labeled` event on the issue timeline by `created_at`), reads the actor's
Expand Down Expand Up @@ -888,7 +901,13 @@ network tool (`gh`, `curl`); the conformance suite runs it in CI, offline.
structured `Blocked by: <id>` body lines; `blocked_by_count` counts blockers whose
file exists and that are `open`, or `closed` with any non-empty `state_reason` other
than `completed` (such as `not_planned` or `duplicate`); those closed ones also count
in `blocked_by_wont_do_count`. A closed item with no `state_reason` was completed. The lease is the same inline marker used
in `blocked_by_wont_do_count`. A closed item with no `state_reason` was completed. An optional
`closed_at` field (a UTC ISO-8601 timestamp string) holds when the item was closed, and
`create-item`'s sibling check reads it (see "Verbs"). This adapter has no close verb (the seam
has none, by design), so no verb writes `state` or `closed_at`: whoever closes an item by
editing its file sets `state: "closed"` and, for the sibling check to count the close as
recent, `closed_at`. A closed item without `closed_at` counts as closed more than 24 hours
ago and never blocks a new create. The lease is the same inline marker used
everywhere (see "Lease protocol"), appended to the item file.
- **Identity.** No authenticated provider user exists offline, so `claim` records the
holder from `git config user.name` (falling back to `$USER`, then `local`) and
Expand Down
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,38 @@ fi
label_list=()
[[ -z "$labels" ]] || IFS=',' read -ra label_list <<<"$labels"

# Sibling dedupe (CONTRACT.md "Verbs"): a child of the parent in the target repo with the
# same trimmed title, open or closed within 24 hours, is returned instead of a new issue.
# One GraphQL read, since the subIssues projection list-sub-items reads has no closedAt.
# A proxy refusal drops to the REST branch, which refuses --parent.
if ((native)) && [[ -n "$parent" ]]; then
wit_require_github_id "$parent" || wit_usage_error "malformed or non-github --parent id: $parent"
# shellcheck disable=SC2016 # GraphQL query: $o/$r/$n are GraphQL variables
wit_run_gh_native read api graphql \
-f query='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){issue(number:$n){subIssues(first:100){nodes{number title state closedAt repository{nameWithOwner}}}}}}' \
-f o="$WIT_ID_OWNER" -f r="$WIT_ID_REPO" -F n="$WIT_ID_NUMBER"
if (($? == 10)); then
native=0
else
sibling="$(jq -r --arg title "$title" --arg repo "$target_repo" '
def trimmed: gsub("^[[:space:]]+|[[:space:]]+$"; "");
($title | trimmed) as $t
| [(.data.repository.issue.subIssues.nodes // [])[]
| select(((.repository.nameWithOwner // "") | ascii_downcase) == ($repo | ascii_downcase))
| select(((.title // "") | trimmed) == $t)
| select(.state == "OPEN" or (.closedAt != null
and ((.closedAt | sub("\\.[0-9]+"; "") | fromdateiso8601) > now - 86400)))]
| (map(select(.state == "OPEN")) + map(select(.state != "OPEN")))[0] // empty
| "\(.number) \(.state | ascii_downcase)"' <<<"$WIT_GH_OUT")" || exit "$EX_INTERNAL"
if [[ -n "$sibling" ]]; then
printf 'create-item: %s already has %s child github:%s#%s titled "%s"; created nothing\n' \
"$parent" "${sibling#* }" "$target_repo" "${sibling%% *}" "$title" >&2
wit_emit_item "${target_repo%%/*}" "${target_repo##*/}" "${sibling%% *}"
exit 0
fi
fi
fi

if ((native)); then
args=(issue create -R "$target_repo" --title "$title" --body "$body")
[[ -z "$type" ]] || args+=(--type "$type")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,19 @@ case "$1 $2" in
;;
"issue view")
if ((native)); then
printf '{"number":42,"title":"t","state":"OPEN","assignees":[],"labels":[{"name":"a"}],"url":"https://github.com/o/r/issues/42"}\n'
printf '{"number":%s,"title":"t","state":"OPEN","assignees":[],"labels":[{"name":"a"}],"url":"https://github.com/o/r/issues/%s"}\n' "$3" "$3"
exit 0
fi
;;
"api graphql")
if ((native)); then
no_children='{"data":{"repository":{"issue":{"subIssues":{"nodes":[]}}}}}'
printf '%s\n' "${GH_STUB_SUBISSUES:-$no_children}"
exit 0
fi
printf 'HTTP 403: GitHub GraphQL is not available from Claude Code sessions\n' >&2
exit 1
;;
esac
case "$1" in
issue | repo)
Expand Down Expand Up @@ -131,6 +140,43 @@ EOF
assert_eq "create-item on gh 2.94 keeps the native path → exit 0" "0" "$rc"
assert_eq "create-item on gh 2.94 emits id" "github:o/r#42" "$(jq -r '.id' <<<"$OUT")"
assert_contains "create-item on gh 2.94 calls issue create" "$(<"$STUB/calls.log")" "issue create"
assert_not_contains "create-item without --parent reads no sub-issues" "$(<"$STUB/calls.log")" "api graphql"

# --- sibling dedupe under --parent: open, closed within 24h, closed earlier ---
siblings() { # <state> <closedAt or null> <title>
printf '{"data":{"repository":{"issue":{"subIssues":{"nodes":[{"number":7,"title":%s,"state":"%s","closedAt":%s,"repository":{"nameWithOwner":"O/R"}}]}}}}}' \
"$(jq -cn --arg t "$3" '$t')" "$1" "$2"
}
hour_ago="\"$(jq -rn 'now - 3600 | todate')\""
two_days_ago="\"$(jq -rn 'now - 172800 | todate')\""
DEDUPE_ERR="$(mktemp "$TMP_ROOT/f.XXXXXX")"

OUT="$(GH_STUB_SUBISSUES="$(siblings OPEN null ' T ')" GH_STUB_VERSION=2.94.0 \
run_create --title T --parent github:o/r#1 --repo o/r 2>"$DEDUPE_ERR")"
rc=$?
assert_eq "create-item --parent with an open sibling titled T → exit 0" "0" "$rc"
assert_eq "create-item --parent with an open sibling prints its id" "github:o/r#7" "$(jq -r '.id' <<<"$OUT")"
assert_not_contains "create-item --parent with an open sibling creates no issue" "$(<"$STUB/calls.log")" "issue create"
assert_contains "create-item --parent with an open sibling names it on stderr" "$(<"$DEDUPE_ERR")" "github:o/r#7"

OUT="$(GH_STUB_SUBISSUES="$(siblings CLOSED "$hour_ago" T)" GH_STUB_VERSION=2.94.0 \
run_create --title T --parent github:o/r#1 --repo o/r 2>"$DEDUPE_ERR")"
rc=$?
assert_eq "create-item --parent with a sibling closed 1h ago → exit 0" "0" "$rc"
assert_eq "create-item --parent with a sibling closed 1h ago prints its id" "github:o/r#7" "$(jq -r '.id' <<<"$OUT")"
assert_not_contains "create-item --parent with a sibling closed 1h ago creates no issue" "$(<"$STUB/calls.log")" "issue create"

OUT="$(GH_STUB_SUBISSUES="$(siblings CLOSED "$two_days_ago" T)" GH_STUB_VERSION=2.94.0 \
run_create --title T --parent github:o/r#1 --repo o/r 2>"$DEDUPE_ERR")"
rc=$?
assert_eq "create-item --parent with a sibling closed 2 days ago → exit 0" "0" "$rc"
assert_eq "create-item --parent with a sibling closed 2 days ago creates a new issue" "github:o/r#42" "$(jq -r '.id' <<<"$OUT")"
assert_contains "create-item --parent with a sibling closed 2 days ago calls issue create" "$(<"$STUB/calls.log")" "issue create"

OUT="$(GH_STUB_SUBISSUES="$(siblings OPEN null 'T2')" GH_STUB_VERSION=2.94.0 \
run_create --title T --parent github:o/r#1 --repo o/r 2>"$DEDUPE_ERR")"
assert_eq "create-item --parent with a differently titled sibling creates a new issue" "github:o/r#42" "$(jq -r '.id' <<<"$OUT")"
rm -f "$DEDUPE_ERR"

# CLAUDE_CODE_REMOTE=true: the cloud proxy refuses GraphQL, so gh 2.94 takes REST.
OUT="$(CLAUDE_CODE_REMOTE=true GH_STUB_VERSION=2.94.0 run_create --title t --labels a --repo o/r)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ search, and aggregation stay on those verbs; do not invent a query language
against the markdown files. `--assignee`, `--search`, and `stats` projections
that need item bodies or update times are **unsupported** on this adapter.
`create-item` records `created_at`; items created before it read `null`.
The adapter has no close verb: close an item by editing its file to `state:
"closed"` and adding `closed_at` (a UTC ISO-8601 string, e.g.
`"2026-11-03T12:00:00Z"`). Without `closed_at`, `create-item --parent` counts
the closed child as closed more than 24 hours ago and files a new same-titled
sibling (CONTRACT.md "local-markdown adapter").

## Auth

Expand Down
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,33 @@ for blocker in "${blocker_ids[@]+"${blocker_ids[@]}"}"; do
}
done

# Sibling dedupe (CONTRACT.md "Verbs"): a child of the parent with the same trimmed title,
# open or closed within 24 hours by its `closed_at`, is returned instead of a new item. A
# closed child with no `closed_at` counts as closed earlier. An open match wins over a closed one.
if [[ -n "$parent" ]]; then
sibling="" sibling_state=""
for n in $(wit_item_numbers); do
file="$(wit_item_file "$n")"
[[ "$(wit_fm_field "$file" parent)" == "$parent_id" ]] || continue
state="$(wit_fm_field "$file" state)"
jq -en --arg title "$title" --argjson stored "$(wit_fm_field "$file" title)" \
--argjson state "$state" --argjson closed_at "$(wit_fm_field "$file" closed_at | grep . || echo null)" '
def trimmed: gsub("^[[:space:]]+|[[:space:]]+$"; "");
($stored | trimmed) == ($title | trimmed)
and ($state == "open" or ($state == "closed" and ($closed_at | type) == "string"
and (($closed_at | sub("\\.[0-9]+"; "") | fromdateiso8601) > now - 86400)))' >/dev/null 2>&1 || continue
sibling="$n" sibling_state="$state"
[[ "$state" == '"open"' ]] && break
done
if [[ -n "$sibling" ]]; then
printf 'create-item: %s already has %s child %s titled "%s"; created nothing\n' \
"$parent" "$(jq -r . <<<"$sibling_state")" \
"$(jq -r . <<<"$(wit_fm_field "$(wit_item_file "$sibling")" id)")" "$title" >&2
wit_emit_local_item "$sibling"
exit 0
fi
fi

ns="${repo_override:-$WIT_LOCAL_DEFAULT_NS}"
# owner/repo must match the ID grammar's char class (lib/id.sh); an out-of-grammar
# --repo would otherwise mint an ID that get-item and later verbs cannot parse.
Expand Down
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,54 @@ WIT_STORAGE_DIR="$STORE" bash "$S" --title orphan --blocked-by "local-markdown:l
assert_eq "a missing --blocked-by is not-found (5), not a dead edge" "5" "$?"
rm -rf "$STORE"

# Sibling dedupe under --parent: an open child with the same trimmed title, or one
# closed within 24 hours by its closed_at, is returned; one closed earlier is not.
close_child() { # <file> <closed_at JSON, or empty for none>
awk -v c="$2" '$0 == "state: \"open\"" { print "state: \"closed\""; if (c != "") print "closed_at: " c; next } { print }' \
"$1" >"$1.new" && mv "$1.new" "$1"
}
STORE="$(mktemp -d "$TMP_ROOT/d.XXXXXX")"
PARENT="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title parent | jq -r '.id')"
CHILD="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title " T " --parent "$PARENT" | jq -r '.id')"
CHILD_FILE="$STORE/${CHILD##*#}.md"
ERR="$(mktemp "$TMP_ROOT/f.XXXXXX")"

OUT="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title T --parent "$PARENT" 2>"$ERR")"
assert_eq "an open sibling titled T → exit 0" "0" "$?"
assert_eq "an open sibling titled T prints its id" "$CHILD" "$(jq -r '.id' <<<"$OUT")"
assert_eq "an open sibling titled T creates no item" "2" "$(find "$STORE" -name '*.md' | wc -l | tr -d ' ')"
assert_contains "an open sibling titled T is named on stderr" "$(<"$ERR")" "$CHILD"

close_child "$CHILD_FILE" "\"$(jq -rn 'now - 3600 | todate')\""
OUT="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title T --parent "$PARENT" 2>"$ERR")"
assert_eq "a sibling closed 1h ago → exit 0" "0" "$?"
assert_eq "a sibling closed 1h ago prints its id" "$CHILD" "$(jq -r '.id' <<<"$OUT")"
assert_eq "a sibling closed 1h ago creates no item" "2" "$(find "$STORE" -name '*.md' | wc -l | tr -d ' ')"

OUT="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title T 2>"$ERR")"
assert_eq "without --parent a same-titled item is still created" "false" \
"$(jq -r --arg c "$CHILD" '.id == $c' <<<"$OUT")"
rm -rf "$STORE"

STORE="$(mktemp -d "$TMP_ROOT/d.XXXXXX")"
PARENT="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title parent | jq -r '.id')"
CHILD="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title T --parent "$PARENT" | jq -r '.id')"
close_child "$STORE/${CHILD##*#}.md" "\"$(jq -rn 'now - 172800 | todate')\""
OUT="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title T --parent "$PARENT" 2>"$ERR")"
assert_eq "a sibling closed 2 days ago → exit 0" "0" "$?"
assert_eq "a sibling closed 2 days ago does not block a new create" "false" \
"$(jq -r --arg c "$CHILD" '.id == $c' <<<"$OUT")"
assert_eq "a sibling closed 2 days ago: the new item is a child of the parent" "$PARENT" \
"$(jq -r '.parent_id' <<<"$OUT")"
rm -rf "$STORE"

STORE="$(mktemp -d "$TMP_ROOT/d.XXXXXX")"
PARENT="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title parent | jq -r '.id')"
CHILD="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title T --parent "$PARENT" | jq -r '.id')"
close_child "$STORE/${CHILD##*#}.md" ""
OUT="$(WIT_STORAGE_DIR="$STORE" bash "$S" --title T --parent "$PARENT" 2>"$ERR")"
assert_eq "a closed sibling with no closed_at does not block a new create" "false" \
"$(jq -r --arg c "$CHILD" '.id == $c' <<<"$OUT")"
rm -rf "$STORE" "$ERR"

[[ $FAILED -eq 0 ]] || exit 1
Loading