diff --git a/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh b/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh new file mode 100644 index 00000000..65fdb1a0 --- /dev/null +++ b/packages/agents/content/scripts/__tests__/get_ticket_id_test.sh @@ -0,0 +1,417 @@ +#!/usr/bin/env bash + +# Source the script under test (main guard prevents execution). +Include "$PROJECT_ROOT/content/scripts/get-ticket-id.sh" + +Describe "extract_jira_id" +It "extracts a bare Jira-style ID" +When call extract_jira_id "COMPPLAN-795" +The output should equal "COMPPLAN-795" +End + +It "extracts a Jira-style ID from an author-prefixed branch" +When call extract_jira_id "wt/COMPPLAN-795" +The output should equal "COMPPLAN-795" +End + +It "extracts a Jira-style ID followed by a description slug" +When call extract_jira_id "wt/JIRA-123-and-some-message" +The output should equal "JIRA-123" +End + +It "extracts a Jira-style ID embedded inside a slug with multiple separators" +When call extract_jira_id "feat/COMPPLAN-795-add-foo" +The output should equal "COMPPLAN-795" +End + +It "preserves the .{N} sub-ticket suffix" +When call extract_jira_id "COMPPLAN-795.2" +The output should equal "COMPPLAN-795.2" +End + +It "preserves the .{N} sub-ticket suffix on a prefixed branch" +When call extract_jira_id "wt/COMPPLAN-795.2/some-slug" +The output should equal "COMPPLAN-795.2" +End + +It "returns the first match when multiple Jira-style IDs appear" +When call extract_jira_id "FOO-1/touches-BAR-2" +The output should equal "FOO-1" +End + +It "returns empty when no Jira-style ID is present" +When call extract_jira_id "feat/foo-2" +The output should equal "" +End + +It "returns empty for a plain word branch" +When call extract_jira_id "main" +The output should equal "" +End + +It "does not match a lowercase letter prefix" +When call extract_jira_id "feat-2" +The output should equal "" +End + +It "does not match a lowercase Jira-style ID on an author-prefixed branch" +When call extract_jira_id "wt/mac-130" +The output should equal "" +End +End + +Describe "extract_bare_number" +It "extracts a bare number that is the entire branch name" +When call extract_bare_number "152" +The output should equal "152" +End + +It "extracts a bare number followed by a separator" +When call extract_bare_number "147/feat/foo" +The output should equal "147" +End + +It "extracts a bare number followed by a hyphen" +When call extract_bare_number "42-something" +The output should equal "42" +End + +It "extracts a bare number followed by an underscore" +When call extract_bare_number "42_something" +The output should equal "42" +End + +It "returns empty when digits are not at the start of the branch name" +When call extract_bare_number "feat/foo-2" +The output should equal "" +End + +It "returns empty when the branch contains no digits" +When call extract_bare_number "main" +The output should equal "" +End +End + +Describe "read_ticket_ref_prefix" +setup_tmpdir() { + tmpdir=$(mktemp -d) +} + +cleanup_tmpdir() { + rm -rf "$tmpdir" +} + +BeforeEach "setup_tmpdir" +AfterEach "cleanup_tmpdir" + +It "returns the value of a single-quoted prefix" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + ticket_ref_prefix: 'MAC-' +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "MAC-" +End + +It "returns the value of a double-quoted prefix" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + ticket_ref_prefix: "MAC-" +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "MAC-" +End + +It "returns # for a single-quoted hash even when followed by an inline comment" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + ticket_ref_prefix: '#' # ignored in file names +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "#" +End + +It "returns the value of an unquoted prefix" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + ticket_ref_prefix: MAC- +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "MAC-" +End + +It "strips a trailing inline comment from an unquoted prefix" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + ticket_ref_prefix: MAC- # tracked in Jira +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "MAC-" +End + +It "returns empty when the key is absent" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + slug: example +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "" +End + +It "returns empty when the file does not exist" +When call read_ticket_ref_prefix "$tmpdir/nonexistent.yaml" +The output should equal "" +End + +It "returns empty when the value is absent" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + ticket_ref_prefix: +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "" +End + +It "returns empty when the value is replaced by an inline comment" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + ticket_ref_prefix: # legacy comment, no value +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "" +End + +It "skips a commented-out preference line and reads the active one below it" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + # ticket_ref_prefix: 'OLD-' + ticket_ref_prefix: 'NEW-' +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "NEW-" +End + +It "returns empty when only a commented-out preference line is present" +write_yaml() { + cat >"$tmpdir/prefs.yaml" <<'YAML' +project: + # ticket_ref_prefix: 'OLD-' +YAML +} +test_read() { + write_yaml + read_ticket_ref_prefix "$tmpdir/prefs.yaml" +} +When call test_read +The output should equal "" +End + +It "defaults to .agents/preferences.yaml in the current working directory" +prepare() { + mkdir -p "$tmpdir/workdir/.agents" + cat >"$tmpdir/workdir/.agents/preferences.yaml" <<'YAML' +project: + ticket_ref_prefix: 'MAC-' +YAML + cd "$tmpdir/workdir" +} +test_read() { + prepare + read_ticket_ref_prefix +} +When call test_read +The output should equal "MAC-" +End +End + +Describe "format_bare_ticket_id" +It "returns the bare number alone for the # prefix" +When call format_bare_ticket_id "152" "#" +The output should equal "152" +End + +It "concatenates a Jira-style prefix with the bare number" +When call format_bare_ticket_id "147" "MAC-" +The output should equal "MAC-147" +End + +It "returns the bare number alone for an empty prefix" +When call format_bare_ticket_id "42" "" +The output should equal "42" +End +End + +Describe "extract_ticket_id (end-to-end via main)" +script="$PROJECT_ROOT/content/scripts/get-ticket-id.sh" + +setup_tmpdir() { + tmpdir=$(mktemp -d) + original_pwd="$PWD" + mkdir -p "$tmpdir/workdir/.agents" + cd "$tmpdir/workdir" +} + +cleanup_tmpdir() { + cd "$original_pwd" + rm -rf "$tmpdir" +} + +BeforeEach "setup_tmpdir" +AfterEach "cleanup_tmpdir" + +# Each end-to-end case runs the script as a child process so the +# preferences-file lookup honors the test's working directory. + +It "returns COMPPLAN-795 for an author-prefixed branch" +When run bash "$script" "wt/COMPPLAN-795" +The output should equal "COMPPLAN-795" +The status should be success +End + +It "returns JIRA-123 for an embedded ticket inside a slug" +When run bash "$script" "wt/JIRA-123-and-some-message" +The output should equal "JIRA-123" +The status should be success +End + +It "returns COMPPLAN-795 for a feat-prefixed branch with a description" +When run bash "$script" "feat/COMPPLAN-795-add-foo" +The output should equal "COMPPLAN-795" +The status should be success +End + +It "returns COMPPLAN-795 for a bare Jira-style branch" +When run bash "$script" "COMPPLAN-795" +The output should equal "COMPPLAN-795" +The status should be success +End + +It "preserves the sub-ticket suffix" +When run bash "$script" "COMPPLAN-795.2" +The output should equal "COMPPLAN-795.2" +The status should be success +End + +It "returns 152 for a bare-numeric branch with the # prefix" +write_prefs() { + cat >".agents/preferences.yaml" <<'YAML' +project: + ticket_ref_prefix: '#' +YAML +} +run_script() { + write_prefs + bash "$script" "152" +} +When call run_script +The output should equal "152" +End + +It "returns MAC-147 for a bare-numeric branch with a Jira-style prefix" +write_prefs() { + cat >".agents/preferences.yaml" <<'YAML' +project: + ticket_ref_prefix: 'MAC-' +YAML +} +run_script() { + write_prefs + bash "$script" "147/feat/foo" +} +When call run_script +The output should equal "MAC-147" +End + +It "returns the bare number when no preferences file exists" +When run bash "$script" "42" +The output should equal "42" +The status should be success +End + +It "returns empty for a slug-embedded number with no leading bare number" +When run bash "$script" "feat/foo-2" +The output should equal "" +The status should be success +End + +It "returns empty for a plain word branch" +When run bash "$script" "main" +The output should equal "" +The status should be success +End + +It "prefers a Jira-style match over a leading bare number when both could apply" +When run bash "$script" "123/MAC-456" +The output should equal "MAC-456" +The status should be success +End + +It "ignores an inline comment when reading a single-quoted hash prefix" +write_prefs() { + cat >".agents/preferences.yaml" <<'YAML' +project: + ticket_ref_prefix: '#' # ignored in file names +YAML +} +run_script() { + write_prefs + bash "$script" "525" +} +When call run_script +The output should equal "525" +End +End diff --git a/packages/agents/content/scripts/get-ticket-id.sh b/packages/agents/content/scripts/get-ticket-id.sh new file mode 100755 index 00000000..de9fb8c0 --- /dev/null +++ b/packages/agents/content/scripts/get-ticket-id.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Extract a Jira-style ticket ID from a branch name. +# +# Tries the Jira-style pattern first (`[A-Z]+-[0-9]+` with an optional +# `.{N}` sub-ticket suffix). Falls back to a bare-numeric match anchored +# at the start of the branch name, formatted using `project.ticket_ref_prefix` +# from `.agents/preferences.yaml`. +# +# Usage: +# get-ticket-id.sh [BRANCH_NAME] +# +# Arguments: +# BRANCH_NAME Branch to extract from. Defaults to the current git branch. +# +# Output: The resolved ticket ID on stdout, or an empty string when no ID +# can be derived. Exit status is always 0. + +set -euo pipefail + +readonly PROG="$(basename "$0")" + +# Match a Jira-style ticket ID anywhere in the branch name. Returns the first +# match or empty. Pattern: one or more uppercase letters, hyphen, one or more +# digits, with an optional `.{N}` sub-ticket suffix. Deliberately unanchored +# so author-prefixed branches (e.g., `wt/COMPPLAN-795`) match correctly. +extract_jira_id() { + local branch_name="$1" + echo "$branch_name" | grep -oE '[A-Z]+-[0-9]+(\.[0-9]+)?' | head -1 || true +} + +# Match a bare-numeric prefix at the start of the branch name. Anchored +# deliberately so digits embedded in slugs (e.g., `feat/foo-2`) do not match. +extract_bare_number() { + local branch_name="$1" + echo "$branch_name" | grep -oE '^[0-9]+' | head -1 || true +} + +# Read `project.ticket_ref_prefix` from a preferences YAML file. Defaults to +# `.agents/preferences.yaml`. Returns empty when the key is absent or the +# file does not exist. Handles both quoted and unquoted values, and strips +# trailing inline comments (`# ...`) from unquoted values. A `#` inside +# single or double quotes is preserved as part of the value. +read_ticket_ref_prefix() { + local file="${1:-.agents/preferences.yaml}" + if [[ ! -f "$file" ]]; then + return + fi + + local line + # Anchor the match at the start of the line (allowing leading whitespace) so + # commented-out preference lines (`# ticket_ref_prefix: ...`) are skipped. + line=$(grep -E '^[[:space:]]*ticket_ref_prefix:' "$file" 2>/dev/null | head -1) || true + if [[ -z "$line" ]]; then + return + fi + + # Strip everything up through `ticket_ref_prefix:` and any leading whitespace. + line="${line#*ticket_ref_prefix:}" + line="${line#"${line%%[![:space:]]*}"}" + + # No value (`ticket_ref_prefix:`) or a comment-only value + # (`ticket_ref_prefix: # note`) both resolve to empty. + if [[ -z "$line" || "$line" == "#"* ]]; then + return + fi + + # Quoted value: capture the contents between the matching quotes. This + # preserves `#` characters that appear inside the value. + if [[ "$line" =~ ^\'([^\']*)\' ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + if [[ "$line" =~ ^\"([^\"]*)\" ]]; then + echo "${BASH_REMATCH[1]}" + return + fi + + # Unquoted value: strip a trailing ` # comment` and surrounding whitespace. + line="${line%% #*}" + line="${line%"${line##*[![:space:]]}"}" + echo "$line" +} + +# Combine a bare number with the configured prefix to produce a ticket ID. +# - `#` prefix: return the bare number alone (the `#` is a GitHub display +# convention and must not appear in returned values or file paths). +# - Other non-empty prefix: return `{prefix}{number}` (e.g., `MAC-147`). +# - Empty prefix: return the bare number unchanged. +format_bare_ticket_id() { + local bare_number="$1" + local prefix="$2" + + if [[ "$prefix" == "#" ]]; then + echo "$bare_number" + elif [[ -n "$prefix" ]]; then + echo "${prefix}${bare_number}" + else + echo "$bare_number" + fi +} + +# Resolve a ticket ID for the given branch name. Tries the Jira-style match +# first; falls back to the bare-numeric prefix when no Jira-style ID is +# found. Returns empty when neither matches. +extract_ticket_id() { + local branch_name="$1" + local ticket_id + + ticket_id="$(extract_jira_id "$branch_name")" + if [[ -n "$ticket_id" ]]; then + echo "$ticket_id" + return + fi + + local bare_number + bare_number="$(extract_bare_number "$branch_name")" + if [[ -z "$bare_number" ]]; then + return + fi + + local prefix + prefix="$(read_ticket_ref_prefix)" + format_bare_ticket_id "$bare_number" "$prefix" +} + +main() { + local branch_name="${1:-}" + if [[ -z "$branch_name" ]]; then + branch_name="$(git branch --show-current)" + fi + extract_ticket_id "$branch_name" +} + +# Allow sourcing for testing without executing main. +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/packages/agents/content/skills/get-ticket-id/SKILL.md b/packages/agents/content/skills/get-ticket-id/SKILL.md index ecba3406..a6c0d021 100644 --- a/packages/agents/content/skills/get-ticket-id/SKILL.md +++ b/packages/agents/content/skills/get-ticket-id/SKILL.md @@ -22,25 +22,13 @@ Extract a Jira-style ticket ID from the current context. ### From branch name -In branch names, `_` and `/` are interchangeable separators (see `branch-format.md`). The regex below extracts correctly regardless of which separator is used. +The script `{platform_home_dir}/scripts/get-ticket-id.sh` extracts a ticket ID from a branch name. It accepts an optional branch name (defaults to the current branch) and prints the resolved ticket ID, or an empty string when no ID can be derived. -```bash -branch_name="${1:-$(git branch --show-current)}" -echo "$branch_name" | grep -oE '[A-Z]+-[0-9]+(\.[0-9]+)?' | head -1 -``` - -### From commit message +In branch names, `_` and `/` are interchangeable separators (see `branch-format.md`). The Jira-style match is unanchored, so it extracts the ID regardless of which separator is used or whether an author/scope prefix appears (e.g., `wt/COMPPLAN-795`, `feat/COMPPLAN-795-add-foo`). -```bash -commit="${1:-HEAD}" -git log -1 --pretty=format:'%s' "$commit" | grep -oE '[A-Z]+-[0-9]+(\.[0-9]+)?' | head -1 -``` +When no Jira-style ID matches, the script falls back to a **bare issue number** anchored to the start of the branch name (terminated by `/`, `_`, `-`, or end-of-string). The anchor on the fallback prevents false matches against digits embedded in slugs like `feat/foo-2`. -### From branch name: bare numeric branches - -If the Jira-style pattern `[A-Z]+-[0-9]+` does not match, check for a **bare issue number**: one or more digits anchored to the start of the branch name, terminated by `/`, `_`, `-`, or end-of-string. - -When a bare number is found, read `project.ticket_ref_prefix` from `.agents/preferences.yaml` to determine the returned ticket ID: +When the bare-numeric fallback fires, the script reads `project.ticket_ref_prefix` from `.agents/preferences.yaml` to format the result: - If `ticket_ref_prefix` is `#`: return the **bare number only**. The `#` is a GitHub display convention and must not appear in file paths or returned values. - If `ticket_ref_prefix` is a Jira-style prefix (e.g., `MAC-`): return `{prefix}{number}` (e.g., `MAC-147`). @@ -54,28 +42,14 @@ When a bare number is found, read `project.ticket_ref_prefix` from `.agents/pref ```bash branch_name="${1:-$(git branch --show-current)}" +ticket_id=$({platform_home_dir}/scripts/get-ticket-id.sh "$branch_name") +``` + +### From commit message -# Try Jira-style first -ticket_id=$(echo "$branch_name" | grep -oE '^[A-Z]+-[0-9]+(\.[0-9]+)?' | head -1) - -# Fall back to bare numeric -if [ -z "$ticket_id" ]; then - bare_number=$(echo "$branch_name" | grep -oE '^[0-9]+' | head -1) - if [ -n "$bare_number" ]; then - # Read ticket_ref_prefix from preferences (yq or manual YAML parsing) - prefix=$(grep 'ticket_ref_prefix:' .agents/preferences.yaml 2>/dev/null \ - | head -1 | sed "s/.*ticket_ref_prefix:[[:space:]]*['\"]\\{0,1\\}\\([^'\"]*\\)['\"]\\{0,1\\}/\\1/") - if [ "$prefix" = "#" ]; then - ticket_id="$bare_number" - elif [ -n "$prefix" ]; then - ticket_id="${prefix}${bare_number}" - else - ticket_id="$bare_number" - fi - fi -fi - -echo "$ticket_id" +```bash +commit="${1:-HEAD}" +git log -1 --pretty=format:'%s' "$commit" | grep -oE '[A-Z]+-[0-9]+(\.[0-9]+)?' | head -1 ``` ### From current context (default)