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
42 changes: 38 additions & 4 deletions packages/agents/content/scripts/__tests__/describe_change_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ BeforeEach "setup_tmpdir"
AfterEach "cleanup_tmpdir"

It "returns empty when no preferences files exist"
When call resolve_title_format "commit"
When call resolve_title_format "commit" ".agents/preferences.yaml"
The output should equal ""
End

Expand All @@ -256,7 +256,7 @@ YAML
}
test_resolve() {
write_global
resolve_title_format "commit"
resolve_title_format "commit" ".agents/preferences.yaml"
}
When call test_resolve
The output should equal "{type}({scope}): {title}"
Expand All @@ -275,7 +275,7 @@ YAML
}
test_resolve() {
write_both
resolve_title_format "commit"
resolve_title_format "commit" ".agents/preferences.yaml"
}
When call test_resolve
The output should equal "project-template"
Expand All @@ -294,7 +294,7 @@ YAML
}
test_resolve() {
write_both
resolve_title_format "commit"
resolve_title_format "commit" ".agents/preferences.yaml"
}
When call test_resolve
The output should equal ""
Expand Down Expand Up @@ -519,6 +519,9 @@ setup_tmpdir() {
export HOME="$tmpdir/home"
mkdir -p "$HOME/.agents"
mkdir -p "$tmpdir/workdir/.agents"
# Make workdir a real repo so the resolver anchors there and the not-a-repo diagnostic stays
# silent; cases that need the not-a-repo path run from a separate uninitialized directory.
git -C "$tmpdir/workdir" init --quiet
cd "$tmpdir/workdir"
}

Expand Down Expand Up @@ -653,4 +656,35 @@ run_script() {
When call run_script
The output should equal '{"commit_title":"line1\nline2","ticket_title":"","pr_title":"","merge_title":""}'
End

It "reads project preferences from the repo root when invoked from a subdirectory"
prepare() {
cat >".agents/preferences.yaml" <<'YAML'
commit:
title_format: '{title}'
YAML
mkdir -p "$tmpdir/workdir/packages/nested"
cd "$tmpdir/workdir/packages/nested"
}
run_script() {
prepare
bash "$script" --title "Add foo"
}
When call run_script
The output should equal '{"commit_title":"Add foo","ticket_title":"","pr_title":"","merge_title":""}'
End

It "warns to stderr when invoked outside a git repository"
# Run from a sibling directory that is deliberately not a repo so the resolver falls back to the
# working directory and must announce the misanchor on stderr rather than failing silently.
run_outside_repo() {
mkdir -p "$tmpdir/outside/.agents"
cd "$tmpdir/outside"
bash "$script" --title "Add foo"
}
When call run_outside_repo
The output should equal '{"commit_title":"","ticket_title":"","pr_title":"","merge_title":""}'
The stderr should include "not inside a git repository"
The status should be success
End
End
39 changes: 37 additions & 2 deletions packages/agents/content/scripts/__tests__/get_ticket_id_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,10 @@ When call test_read
The output should equal ""
End

It "defaults to .agents/preferences.yaml in the current working directory"
It "defaults to the preferences file at the git repo root"
prepare() {
mkdir -p "$tmpdir/workdir/.agents"
git -C "$tmpdir/workdir" init --quiet
cat >"$tmpdir/workdir/.agents/preferences.yaml" <<'YAML'
project:
ticket_ref_prefix: 'MAC-'
Expand Down Expand Up @@ -332,6 +333,9 @@ setup_tmpdir() {
tmpdir=$(mktemp -d)
original_pwd="$PWD"
mkdir -p "$tmpdir/workdir/.agents"
# Make workdir a real repo so the resolver anchors there and the not-a-repo diagnostic stays
# silent; cases that need the not-a-repo path run from a separate uninitialized directory.
git -C "$tmpdir/workdir" init --quiet
cd "$tmpdir/workdir"
}

Expand All @@ -344,7 +348,7 @@ 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.
# preferences-file lookup honors the resolved repo root.

It "returns COMPPLAN-795 for an author-prefixed branch"
When run bash "$script" "wt/COMPPLAN-795"
Expand Down Expand Up @@ -450,4 +454,35 @@ run_script() {
When call run_script
The output should equal "525"
End

It "reads preferences from the repo root when invoked from a subdirectory"
prepare() {
cat >"$tmpdir/workdir/.agents/preferences.yaml" <<'YAML'
project:
ticket_ref_prefix: 'MAC-'
YAML
mkdir -p "$tmpdir/workdir/packages/nested"
cd "$tmpdir/workdir/packages/nested"
}
run_script() {
prepare
bash "$script" "147"
}
When call run_script
The output should equal "MAC-147"
End

It "warns to stderr when a bare number is resolved outside a git repository"
# Run from a sibling directory that is deliberately not a repo so the prefix lookup falls back to
# the working directory and must announce the misanchor on stderr rather than failing silently.
run_outside_repo() {
mkdir -p "$tmpdir/outside"
cd "$tmpdir/outside"
bash "$script" "147"
}
When call run_outside_repo
The output should equal "147"
The stderr should include "not inside a git repository"
The status should be success
End
End
29 changes: 24 additions & 5 deletions packages/agents/content/scripts/describe-change.sh
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ while [[ $# -gt 0 ]]; do
esac
done

# Resolve the project preferences file, anchored at the git repo root so the lookup does not depend on the
# caller's working directory. Falls back to the current directory when not inside a git repository, emitting a
# stderr diagnostic so a misanchored run is debuggable rather than silent.
project_preferences_file() {
local root
if ! root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
root="$PWD"
printf '%s: not inside a git repository; anchoring .agents/ lookup at %s\n' "$PROG" "$root" >&2
fi
printf '%s/.agents/preferences.yaml' "$root"
}

# Parse a specific `title_format` value from a YAML file.
# Reads line-by-line, tracks the current top-level section, and matches
# `title_format:` within the target section (commit, ticket, pr, merge).
Expand Down Expand Up @@ -118,12 +130,15 @@ parse_title_format() {
# Resolve a `title_format` value by checking project, then global, then defaulting to empty.
# parse_title_format returns "FOUND:{value}" when the key is present, or empty when absent.
# This lets an explicit empty value at the project level override a global non-empty value.
# The project preferences path is resolved once by the caller and passed in, so the git-root
# lookup (and its not-a-repo diagnostic) runs once per invocation rather than once per section.
resolve_title_format() {
local section="$1"
local project_prefs_file="$2"
local result

# Project preferences
result="$(parse_title_format ".agents/preferences.yaml" "$section")"
result="$(parse_title_format "$project_prefs_file" "$section")"
if [[ "$result" == FOUND:* ]]; then
echo "${result#FOUND:}"
return
Expand Down Expand Up @@ -256,11 +271,15 @@ json_escape() {
}

main() {
# Resolve the project preferences path once so the git-root lookup runs a single time.
local project_prefs_file
project_prefs_file="$(project_preferences_file)"

local commit_template ticket_template pr_template merge_template
commit_template="$(resolve_title_format "commit")"
ticket_template="$(resolve_title_format "ticket")"
pr_template="$(resolve_title_format "pr")"
merge_template="$(resolve_title_format "merge")"
commit_template="$(resolve_title_format "commit" "$project_prefs_file")"
ticket_template="$(resolve_title_format "ticket" "$project_prefs_file")"
pr_template="$(resolve_title_format "pr" "$project_prefs_file")"
merge_template="$(resolve_title_format "merge" "$project_prefs_file")"

local commit_title ticket_title pr_title merge_title
commit_title="$(json_escape "$(render_title "$commit_template")")"
Expand Down
61 changes: 33 additions & 28 deletions packages/agents/content/scripts/get-ticket-id.sh
Original file line number Diff line number Diff line change
@@ -1,31 +1,27 @@
#!/usr/bin/env bash
# Extract a Jira-style ticket ID from a branch name.
#
# Tries the Jira-style pattern first (case-insensitive `[A-Za-z]{2,}-[0-9]+`,
# uppercased on output). 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`.
# Tries the Jira-style pattern first (case-insensitive `[A-Za-z]{2,}-[0-9]+`, uppercased on output).
# 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.
# 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 (uppercased) or empty. Pattern: Two or more letters, hyphen, one or
# more digits, matched case-insensitively. Deliberately unanchored so
# author-prefixed branches (e.g., `wt/COMPPLAN-795`, `wthorsen/MAC-130`)
# match correctly. The greedy `[0-9]+` boundary stops at the first non-digit,
# so `.N` sub-ticket suffixes and `-description` suffixes are naturally
# truncated. See `_data/ticket-id-extraction.md` for the canonical contract.
# Matches a Jira-style ticket ID anywhere in the branch name. Returns the first match (uppercased) or empty.
# Pattern: Two or more letters, hyphen, one or more digits, matched case-insensitively.
# Deliberately unanchored so author-prefixed branches (e.g., `wt/COMPPLAN-795`, `wthorsen/MAC-130`) match correctly.
# The greedy `[0-9]+` boundary stops at the first non-digit, so `.N` sub-ticket suffixes and `-description` suffixes
# are naturally truncated. See `_data/ticket-id-extraction.md` for the canonical contract.
extract_jira_id() {
local branch_name="$1"
echo "$branch_name" | grep -oiE '[A-Z]{2,}-[0-9]+' | head -1 | tr '[:lower:]' '[:upper:]' || true
Expand All @@ -38,20 +34,31 @@ extract_bare_number() {
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.
# Resolve the project preferences file, anchored at the git repo root so that the lookup does not depend on the
# caller's working directory. Falls back to the current directory when not inside a git repository, emitting a
# stderr diagnostic so a misanchored run is debuggable rather than silent.
project_preferences_file() {
local root
if ! root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
root="$PWD"
printf '%s: not inside a git repository; anchoring .agents/ lookup at %s\n' "$PROG" "$root" >&2
fi
printf '%s/.agents/preferences.yaml' "$root"
}

# Reads `project.ticket_ref_prefix` from a preferences YAML file. Defaults to the preferences file at the repo root.
# 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}"
local file="${1:-$(project_preferences_file)}"
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.
# Anchor the match at the start of the line (allowing leading whitespace) so that 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
Expand All @@ -67,8 +74,7 @@ read_ticket_ref_prefix() {
return
fi

# Quoted value: Capture the contents between the matching quotes. This
# preserves `#` characters that appear inside the value.
# Quoted value: Capture the contents between the matching quotes. This preserves `#` characters inside the value.
if [[ "$line" =~ ^\'([^\']*)\' ]]; then
echo "${BASH_REMATCH[1]}"
return
Expand All @@ -85,8 +91,8 @@ read_ticket_ref_prefix() {
}

# 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).
# - `#` prefix: Return the bare number alone (`#` 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() {
Expand All @@ -102,9 +108,8 @@ format_bare_ticket_id() {
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.
# 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
Expand Down
8 changes: 6 additions & 2 deletions packages/agents/src/derive-session-context/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
*
* Flags:
* --branch <name> Override branch lookup (used by tests and the smoke harness).
* --cwd <path> Override working directory (used by tests).
* --cwd <path> Caller-supplied base directory for repo-relative paths (`.agents/`). When omitted, the base
* resolves to the git repo root (worktree-aware), falling back to the current working directory.
* Callers that already hold the root (e.g. `resolve-frontmatter.sh`) pass it explicitly; tests
* use it for isolation. See `resolveProjectRoot` for the full precedence.
* --home <path> Override home directory for `~/.agents/preferences.yaml` lookup.
* Defaults to `os.homedir()`.
*/
Expand All @@ -27,6 +30,7 @@ import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

import { isEnoent, isRecord } from '../lib/type-guards.ts';
import { resolveProjectRoot } from '../shared/resolve-project-root.ts';
import { composeManifest } from './compose-manifest.ts';
import { readPreferences } from './read-preferences.ts';
import type { BranchManifest } from './types.ts';
Expand Down Expand Up @@ -57,7 +61,7 @@ interface ParsedArgs {
async function main(): Promise<void> {
try {
const parsed = parseArgs(process.argv.slice(2));
const cwd = parsed.cwd ?? process.cwd();
const cwd = resolveProjectRoot({ cwd: parsed.cwd });
const branch = parsed.branch ?? (await resolveCurrentBranch(cwd));

const manifest = await deriveSessionContext({
Expand Down
Loading
Loading