From 9d51251f078aa8e799496339ab2d4233334dfb28 Mon Sep 17 00:00:00 2001 From: Maxim Geraskin Date: Tue, 24 Feb 2026 14:43:08 +0100 Subject: [PATCH] uspecs 0.1.0-a0, 2026-02-24T13:32:43Z --- AGENTS.md | 5 +- uspecs/u/actn-uarchive.md | 18 ++- uspecs/u/actn-uchange.md | 10 +- uspecs/u/actn-udecs.md | 2 +- uspecs/u/actn-uhow.md | 2 +- uspecs/u/actn-uimpl.md | 4 +- uspecs/u/actn-upr.md | 48 +++++++ uspecs/u/conf.md | 20 +-- uspecs/u/scripts/_lib/pr.sh | 235 ++++++++++++++++++++++++++++--- uspecs/u/scripts/_lib/utils.sh | 34 +++++ uspecs/u/scripts/conf.sh | 61 +++++--- uspecs/u/scripts/uspecs.sh | 238 ++++++++++++++++++++++++++++---- uspecs/u/templates/tmpl-fd.md | 11 ++ uspecs/u/templates/tmpl-how.md | 2 +- uspecs/u/templates/tmpl-impl.md | 8 +- uspecs/u/templates/tmpl-pr.md | 23 +++ uspecs/u/uspecs.yml | 6 +- 17 files changed, 632 insertions(+), 95 deletions(-) create mode 100644 uspecs/u/actn-upr.md create mode 100644 uspecs/u/templates/tmpl-pr.md diff --git a/AGENTS.md b/AGENTS.md index 596356b..ff3b2c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agents instructions - + ## Execution instructions @@ -12,7 +12,8 @@ When a request mentions one of the words below, you must read the corresponding - usync: `uspecs/u/actn-usync.md` - udecs: `uspecs/u/actn-udecs.md` - uhow: `uspecs/u/actn-uhow.md` +- upr: `uspecs/u/actn-upr.md` Use files from `./uspecs/u` as an initial reference when user mentions uspecs - + diff --git a/uspecs/u/actn-uarchive.md b/uspecs/u/actn-uarchive.md index 0d3309a..71ebb92 100644 --- a/uspecs/u/actn-uarchive.md +++ b/uspecs/u/actn-uarchive.md @@ -15,11 +15,19 @@ Parameters: - Input - Active Change Folder path - Output - - Folder moved to `$changes_folder/archive` + - Folder moved to `{changes_folder}/archive` + - If on PR branch and Engineer confirms: git commit and push with message, branch and refs removed Flow: -- Identify Active Change Folder to archive, if unclear, ask user to specify folder name -- Execute `bash uspecs/u/scripts/uspecs.sh change archive ` - - Example: `bash uspecs/u/scripts/uspecs.sh change archive 2602211523-check-cmd-availability` -- Analyze output, show to user and stop +- Identify Active Change Folder to archive, if unclear, ask Engineer to specify folder name +- Run `bash uspecs/u/scripts/uspecs.sh status ispr` + - If output is `yes`: present Engineer with the following options: + 1. Archive + git cleanup (commit, push, delete local branch and remote tracking ref) + 2. Archive only (no git operations) + 3. Cancel + - On option 1: `bash uspecs/u/scripts/uspecs.sh change archive -d` + - On option 2: `bash uspecs/u/scripts/uspecs.sh change archive ` + - On option 3: abort, no action taken + - Otherwise: `bash uspecs/u/scripts/uspecs.sh change archive ` +- Analyze output, show to Engineer and stop diff --git a/uspecs/u/actn-uchange.md b/uspecs/u/actn-uchange.md index a6067bf..68f2401 100644 --- a/uspecs/u/actn-uchange.md +++ b/uspecs/u/actn-uchange.md @@ -15,8 +15,8 @@ Parameters: - Input - Change description - --branch option (optional): create git branch for the change - - Issue reference (optional): URL to a GitLab/GitHub/Jira issue that prompted the change - - Referenced further as `$issue_url` + - Issue reference (optional): URL to a GitLab/GitHub/Jira/etc. issue that prompted the change + - Referenced further as `{issue_url}` - Output - Active Change Folder with Change File - Issue File (if issue reference provided) @@ -26,12 +26,12 @@ Flow: - Determine `change_name` from the change description: kebab-case, 15-30 chars, descriptive - Run script to create Change Folder: - - Base command: `bash uspecs/u/scripts/uspecs.sh change new $change_name` - - If issue reference provided add `--issue-url $issue_url` parameters + - Base command: `bash uspecs/u/scripts/uspecs.sh change new {change_name}` + - If issue reference provided add `--issue-url "{issue_url}"` parameters (quoted to handle shell-special characters such as `&`) - If --branch option provided add `--branch` parameter - Fail fast if script exits with error - Parse Change Folder path from script output, path is relative to project root -- Write Change File body (`$templates_folder/tmpl-change.md`) appended to Change File created by the script +- Write Change File body (`{templates_folder}/tmpl-change.md`) appended to Change File created by the script - If issue reference provided: - Try to fetch issue content - If fetch succeeds: diff --git a/uspecs/u/actn-udecs.md b/uspecs/u/actn-udecs.md index 47fa7f8..a87280e 100644 --- a/uspecs/u/actn-udecs.md +++ b/uspecs/u/actn-udecs.md @@ -20,7 +20,7 @@ Parameters: - Optional: area focus (natural language like "focus on authentication", "clarify database design") - Optional: web search (--web flag or phrases like "with web search", "use web", "web") - Output - - Updated Decision File (see `$templates_folder/tmpl-decs.md`) + - Updated Decision File (see `{templates_folder}/tmpl-decs.md`) ## Scenarios diff --git a/uspecs/u/actn-uhow.md b/uspecs/u/actn-uhow.md index b153314..39518f8 100644 --- a/uspecs/u/actn-uhow.md +++ b/uspecs/u/actn-uhow.md @@ -17,7 +17,7 @@ Parameters: - Change File and other files in Active Change Folder - Existing How File (if any) - Output - - Updated How File (see `$templates_folder/tmpl-how.md`) + - Updated How File (see `{templates_folder}/tmpl-how.md`) ## Scenarios diff --git a/uspecs/u/actn-uimpl.md b/uspecs/u/actn-uimpl.md index 7ccd634..50d848e 100644 --- a/uspecs/u/actn-uimpl.md +++ b/uspecs/u/actn-uimpl.md @@ -32,7 +32,7 @@ Flow: - For "some to-do items unchecked" scenario: Implement each unchecked item and check it immediately after implementation (stop at Review Item if unchecked) - For edge cases: Follow the specific scenario behavior -Use definitions from sections below and structures from `$templates_folder/tmpl-impl.md` when executing actions +Use definitions from sections below and structures from `{templates_folder}/tmpl-impl.md` when executing actions ## Definitions @@ -41,7 +41,7 @@ Use definitions from sections below and structures from `$templates_folder/tmpl- The section is needed if: - Domain Files exist and define External actors -- Change Request description impacts Functional Design Specifications (only files inside `$specs_folder`) +- Change Request description impacts Functional Design Specifications (only files inside `{specs_folder}`) Impact: diff --git a/uspecs/u/actn-upr.md b/uspecs/u/actn-upr.md new file mode 100644 index 0000000..623aaea --- /dev/null +++ b/uspecs/u/actn-upr.md @@ -0,0 +1,48 @@ +# Action: Create pull request + +## Overview + +Create a pull request from the current change branch by squash-merging it into a dedicated PR branch and submitting via GitHub CLI. + +## Instructions + +Rules: + +- Strictly follow the definitions from `uspecs/u/concepts.md` and `uspecs/u/conf.md` +- Always call `uspecs.sh` for git/PR operations — never call `_lib/pr.sh` directly +- Read `change.md` frontmatter to determine `issue_url` and `issue_id` + +Parameters: + +- Input + - Active Change Folder (change.md for issue_url) + - change_branch: current git branch +- Output + - PR created on GitHub + - pr_branch: `{change_branch}--pr` with squashed commits + - change_branch deleted + +Flow: + +- Validate preconditions: + - Run `bash uspecs/u/scripts/uspecs.sh pr mergedef --validate` + - If script exits with error: report the error and stop + - Parse `change_branch` and `default_branch` from script output +- Read Active Change Folder (change.md) to determine `issue_url` (may be absent) and derive `issue_id` from the URL (last path segment) +- Present Engineer with the following options: + 1. Create PR (squash-merge `change_branch` into `{change_branch}--pr`, delete `change_branch`, create PR on GitHub) + 2. Cancel + - On option 2: stop +- Merge default branch into change_branch: + - Run `bash uspecs/u/scripts/uspecs.sh pr mergedef` + - If script exits with error: report the error and stop + - If merge fails (conflicts): report error and ask Engineer to resolve conflicts and re-run +- Get specs diff to derive PR title and body: + - Run `bash uspecs/u/scripts/uspecs.sh diff specs` + - From the diff output identify `draft_title` and `draft_body`; construct `pr_title` and `pr_body` per `{templates_folder}/tmpl-pr.md` +- Create PR: + - Pass `pr_body` via stdin to `bash uspecs/u/scripts/uspecs.sh pr create --title "{pr_title}"` + - Note: `pr_title` is passed on the command line; ensure it contains no shell-special characters (`<`, `>`, `$`, backticks) + - If script exits with error: report the error and stop + - Parse `pr_url` from script output +- Report `pr_url` to Engineer; inform that Engineer is now on `pr_branch` to address review comments diff --git a/uspecs/u/conf.md b/uspecs/u/conf.md index d89b325..60a96c8 100644 --- a/uspecs/u/conf.md +++ b/uspecs/u/conf.md @@ -6,7 +6,7 @@ All paths are relative to the project root: - specs_folder: uspecs/specs - changes_folder: uspecs/changes -- changes_archive: `$changes_folder/archive` +- changes_archive: `{changes_folder}/archive` - templates_folder: uspecs/u/templates ## Artifacts @@ -18,13 +18,17 @@ All paths are relative to the project root: - Focus on core action or feature, avoid redundant words - Use abbreviations when appropriate to reduce length - Examples: `remove-uspecs-prefix`, `fetch-issue-to-change`, `alpha-code-bp3-endpoints` - - Can be either Active (in `$changes_folder`) or Archived (in `$changes_archive`) + - Can be either Active (in `{changes_folder}`) or Archived (in `{changes_archive}`) - Active Change Folder files describe Active Change Request and its implementation - Branch naming for Change Folder (when --branch option used): - Format: `{change-name}` (without timestamp prefix) - Example: For Change Folder `2602141423-branch-option-uchange`, branch name is `branch-option-uchange` - Branch is created from current HEAD after Change Folder and Change File are created - If branch creation fails, error is reported but change creation continues + - PR branch naming (created by upr): + - Format: `{change-name}--pr` + - Example: For change branch `branch-option-uchange`, PR branch is `branch-option-uchange--pr` + - Created from pr_remote/default_branch; contains a single squashed commit - Change Folder System Artifacts - Change File: `change.md` - Issue File: `issue.md` @@ -34,13 +38,13 @@ All paths are relative to the project root: - Clarification and brainstorming about Change Request functional and technical design - How File: `how.md` - Implementation approach idea for a Change Request -- Domain Folder: `$specs_folder/{domain}/` -- Context Folder: `$specs_folder/{domain}/{context-id}/` +- Domain Folder: `{specs_folder}/{domain}/` +- Context Folder: `{specs_folder}/{domain}/{context-id}/` - Contains - zero or many `Scenarios File`, `Requirements File`, `Technical Design File` - zero or one `Architecture File` - Functional Design Specifications - - Domain File: `$specs_folder/{domain}/{domain}--domain.md` + - Domain File: `{specs_folder}/{domain}/{domain}--domain.md` - Feature Files - Scenarios File: `{context-folder}/{feature}.feature` - Requirements File: `{context-folder}/{feature}--reqs.md` @@ -49,12 +53,12 @@ All paths are relative to the project root: - Files like `go.mod`, `go.work`, `package.json`, `requirements.txt`, `pubspec.yaml` etc. that define project dependencies and configuration - Technical Design Specifications - Domain Technology - - Per domain: `$specs_folder/{domain}/{domain}--tech.md` + - Per domain: `{specs_folder}/{domain}/{domain}--tech.md` - Defines tech stack, architecture patterns etc., UI/UX guidelines etc. - Domain Architecture - - `$specs_folder/{domain}/{domain}--arch.md` + - `{specs_folder}/{domain}/{domain}--arch.md` - Domain Subsystem Architecture - or `$specs_folder/{domain}/{subsystem}--arch.md` + or `{specs_folder}/{domain}/{subsystem}--arch.md` - Context Architecture - `{context-folder}/{context}--arch.md` - Context Subsystem Architecture diff --git a/uspecs/u/scripts/_lib/pr.sh b/uspecs/u/scripts/_lib/pr.sh index 9959066..1725378 100644 --- a/uspecs/u/scripts/_lib/pr.sh +++ b/uspecs/u/scripts/_lib/pr.sh @@ -9,6 +9,8 @@ set -Eeuo pipefail # Concepts: # pr_remote The remote that owns the target branch for PRs. # "upstream" when a fork setup is detected, otherwise "origin". +# change_branch The current working branch (named {change-name}). +# pr_branch The squashed PR branch (named {change-name}--pr). # # Usage: # pr.sh info @@ -19,6 +21,22 @@ set -Eeuo pipefail # pr.sh prbranch # Fetch pr_remote and create a local branch from its default branch. # +# pr.sh mergedef +# Fetch pr_remote and merge pr_remote/default_branch into the current branch. +# +# pr.sh diff specs +# Output git diff of the specs folder between HEAD and pr_remote/default_branch. +# +# pr.sh changepr --title --body <body> +# Create a PR from the current change_branch: +# - Fail fast if pr_branch ({change_branch}--pr) already exists. +# - Create pr_branch from pr_remote/default_branch. +# - Squash-merge change_branch into pr_branch and commit with title. +# - Push pr_branch to origin and create a PR via GitHub CLI. +# - Delete change_branch (locally, tracking ref, and remote; skip if absent). +# - Output pr_url on success. +# - On failure after pr_branch creation: roll back pr_branch, preserve change_branch. +# # pr.sh pr --title <title> --body <body> --next-branch <branch> [--delete-branch] # Stage all changes, commit, push to origin, and open a PR against # pr_remote's default branch. Switch to --next-branch afterwards. @@ -37,6 +55,38 @@ set -Eeuo pipefail # Helpers # --------------------------------------------------------------------------- +# shellcheck source=utils.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/utils.sh" + +get_project_dir() { + local script_dir + script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + # _lib/ -> scripts/ -> u/ -> uspecs/ -> project root + cd "$script_dir/../../../.." && pwd +} + +read_conf_param() { + local param_name="$1" + local conf_file + conf_file="$(get_project_dir)/uspecs/u/conf.md" + + if [ ! -f "$conf_file" ]; then + error "conf.md not found: $conf_file" + fi + + local line raw + line=$(grep -E "^- ${param_name}:" "$conf_file" | head -1 || true) + raw="${line#*: }" + + if [ -z "$raw" ]; then + error "Parameter '${param_name}' not found in conf.md" + fi + + local value + value=$(echo "$raw" | sed 's/^[[:space:]`]*//' | sed 's/[[:space:]`]*$//') + echo "$value" +} + error() { echo "Error: $1" >&2 exit 1 @@ -50,6 +100,24 @@ determine_pr_remote() { fi } +gh_create_pr() { + # Usage: printf '%s' "$body" | gh_create_pr <pr_remote> <default_branch> <head_branch> <title> + # Creates a PR via GitHub CLI and outputs the PR URL. Reads body from stdin. + local pr_remote="$1" default_branch="$2" head_branch="$3" title="$4" + + local pr_repo + pr_repo="$(git remote get-url "$pr_remote" | sed -E 's#.*github.com[:/]##; s#\.git$##')" + local pr_args=('--repo' "$pr_repo" '--base' "$default_branch" '--title' "$title" '--body-file' '-') + + if [[ "$pr_remote" == "upstream" ]]; then + local origin_owner + origin_owner="$(git remote get-url origin | sed -E 's#.*github.com[:/]##; s#\.git$##; s#/.*##')" + gh pr create "${pr_args[@]}" --head "${origin_owner}:${head_branch}" + else + gh pr create "${pr_args[@]}" --head "$head_branch" + fi +} + check_prerequisites() { # Check if git repository exists local dir="$PWD" @@ -113,7 +181,7 @@ cmd_prbranch() { default_branch=$(default_branch_name) echo "Fetching $pr_remote/$default_branch..." - git fetch "$pr_remote" "$default_branch" + git fetch "$pr_remote" "$default_branch" 2>&1 echo "Creating branch: $name" git checkout -b "$name" "$pr_remote/$default_branch" @@ -134,10 +202,10 @@ cmd_ffdefault() { fi echo "Fetching $pr_remote/$default_branch..." - git fetch "$pr_remote" "$default_branch" + git fetch "$pr_remote" "$default_branch" 2>&1 echo "Fast-forwarding $current_branch..." - if ! git merge --ff-only "$pr_remote/$default_branch"; then + if ! git merge --ff-only "$pr_remote/$default_branch" 2>&1; then error "Cannot fast-forward '$current_branch' to '$pr_remote/$default_branch'. The branches have diverged." fi } @@ -187,18 +255,8 @@ cmd_pr() { git push -u origin "$branch_name" echo "Creating pull request to $pr_remote..." - local pr_repo - pr_repo="$(git remote get-url "$pr_remote" | sed -E 's#.*github.com[:/]##; s#\.git$##')" - local pr_args=('--repo' "$pr_repo" '--base' "$default_branch" '--title' "$title" '--body' "$body") - local pr_url - if [[ "$pr_remote" == "upstream" ]]; then - local origin_owner - origin_owner="$(git remote get-url origin | sed -E 's#.*github.com[:/]##; s#\.git$##; s#/.*##')" - pr_url=$(gh pr create "${pr_args[@]}" --head "${origin_owner}:${branch_name}") - else - pr_url=$(gh pr create "${pr_args[@]}" --head "$branch_name") - fi + pr_url=$(printf '%s' "$body" | gh_create_pr "$pr_remote" "$default_branch" "$branch_name" "$title") echo "Pull request created successfully!" echo "Switching to $next_branch..." @@ -216,19 +274,164 @@ cmd_pr() { echo "PR_BASE=$default_branch" >&2 } +cmd_mergedef() { + local validate_only=false + while [[ $# -gt 0 ]]; do + case "$1" in + --validate) validate_only=true; shift ;; + *) error "Unknown flag: $1" ;; + esac + done + + check_prerequisites + + local pr_remote default_branch current_branch + pr_remote=$(determine_pr_remote) + default_branch=$(default_branch_name) + current_branch=$(git symbolic-ref --short HEAD) + + if [[ "$current_branch" == "$default_branch" ]]; then + error "Current branch '$current_branch' is the default branch; cannot create PR from it" + fi + + if [[ "$current_branch" == *--pr ]]; then + error "Current branch '$current_branch' ends with '--pr'; cannot create PR from a PR branch" + fi + + if [[ "$validate_only" == "true" ]]; then + echo "change_branch=$current_branch" + echo "default_branch=$default_branch" + return 0 + fi + + echo "Fetching $pr_remote/$default_branch..." + git fetch "$pr_remote" "$default_branch" 2>&1 + + echo "Merging $pr_remote/$default_branch into $current_branch..." + git merge "$pr_remote/$default_branch" 2>&1 +} + +cmd_diff() { + local target="${1:-}" + [[ -z "$target" ]] && error "Usage: pr.sh diff <target>" + + local diff_path + case "$target" in + specs) + diff_path=$(read_conf_param "specs_folder") + ;; + *) + error "Unknown diff target: $target. Available: specs" + ;; + esac + + local pr_remote default_branch + pr_remote=$(determine_pr_remote) + default_branch=$(default_branch_name) + + local project_dir + project_dir=$(get_project_dir) + + git fetch "$pr_remote" "$default_branch" >/dev/null 2>&1 || true + (cd "$project_dir" && git diff "$pr_remote/$default_branch" HEAD -- "$diff_path") +} + +cmd_changepr() { + local title="" body="" + while [[ $# -gt 0 ]]; do + case "$1" in + --title) title="$2"; shift 2 ;; + --body) body="$2"; shift 2 ;; + *) error "Unknown flag: $1" ;; + esac + done + [[ -z "$title" ]] && error "--title is required" + if [[ -z "$body" ]]; then + if is_tty; then + error "--body is required (or pipe body via stdin)" + fi + body=$(cat) + fi + [[ -z "$body" ]] && error "--body is required (or pipe body via stdin)" + + local pr_remote default_branch change_branch pr_branch + pr_remote=$(determine_pr_remote) + default_branch=$(default_branch_name) + change_branch=$(git symbolic-ref --short HEAD) + pr_branch="${change_branch}--pr" + + # Fail fast if pr_branch already exists + if git show-ref --verify --quiet "refs/heads/$pr_branch"; then + error "PR branch '$pr_branch' already exists" + fi + + # Create pr_branch from pr_remote/default_branch + cmd_prbranch "$pr_branch" + + # Rollback pr_branch on failure; preserve change_branch + local success=false + rollback_pr_branch() { + if [[ "$success" != "true" ]]; then + echo "Rolling back: removing pr_branch '$pr_branch'..." >&2 + git checkout "$change_branch" 2>/dev/null || true + git branch -D "$pr_branch" 2>/dev/null || true + git push origin --delete "$pr_branch" 2>/dev/null || true + git branch -dr "origin/$pr_branch" 2>/dev/null || true + fi + } + trap rollback_pr_branch ERR + + # Squash-merge change_branch into pr_branch and commit + echo "Squash-merging $change_branch into $pr_branch..." + git merge --squash "$change_branch" + git commit -m "$title" + + # Push pr_branch to origin + echo "Pushing $pr_branch to origin..." + git push -u origin "$pr_branch" + + # Create PR via GitHub CLI + echo "Creating pull request..." + local pr_url + pr_url=$(printf '%s' "$body" | gh_create_pr "$pr_remote" "$default_branch" "$pr_branch" "$title") + + success=true + trap - ERR + + # Delete change_branch (locally, tracking ref, and remote; skip silently if absent) + echo "Deleting change branch $change_branch..." + if git show-ref --verify --quiet "refs/heads/$change_branch"; then + if ! git branch -D "$change_branch" 2>/dev/null; then + echo "Warning: failed to delete local branch '$change_branch'" >&2 + fi + fi + git branch -dr "origin/$change_branch" 2>/dev/null || true + if ! git push origin --delete "$change_branch" 2>/dev/null; then + # Warn only if the remote branch actually existed + if git ls-remote --exit-code --heads origin "$change_branch" >/dev/null 2>&1; then + echo "Warning: failed to delete remote branch 'origin/$change_branch'" >&2 + fi + fi + + echo "$pr_url" +} + # --------------------------------------------------------------------------- # Dispatch # --------------------------------------------------------------------------- if [[ $# -lt 1 ]]; then - error "Usage: pr.sh <info|prbranch|pr|ffdefault> [args...]" + error "Usage: pr.sh <info|prbranch|mergedef|diff|changepr|pr|ffdefault> [args...]" fi command="$1"; shift case "$command" in info) cmd_info "$@" ;; prbranch) cmd_prbranch "$@" ;; + mergedef) cmd_mergedef "$@" ;; + diff) cmd_diff "$@" ;; + changepr) cmd_changepr "$@" ;; pr) cmd_pr "$@" ;; ffdefault) cmd_ffdefault "$@" ;; - *) error "Unknown command: $command. Available: info, prbranch, pr, ffdefault" ;; + *) error "Unknown command: $command. Available: info, prbranch, mergedef, diff, changepr, pr, ffdefault" ;; esac diff --git a/uspecs/u/scripts/_lib/utils.sh b/uspecs/u/scripts/_lib/utils.sh index 6753eb8..59abb77 100644 --- a/uspecs/u/scripts/_lib/utils.sh +++ b/uspecs/u/scripts/_lib/utils.sh @@ -12,3 +12,37 @@ checkcmds() { fi done } + +# get_pr_info <pr_sh_path> <map_nameref> +# Calls pr.sh info and parses the key=value output into the given associative array. +# Keys populated: pr_remote, default_branch +# Returns non-zero if pr.sh info fails. +get_pr_info() { + local pr_sh="$1" + local -n _pr_info_map="$2" + local output + output=$(bash "$pr_sh" info) || return 1 + while IFS='=' read -r key value; do + [[ -z "$key" ]] && continue + _pr_info_map["$key"]="$value" + done <<< "$output" +} + +# is_tty +# Returns 0 if stdin is connected to a terminal, 1 if piped or redirected. +is_tty() { + [ -t 0 ] +} + +# sed_inplace file sed-args... +# Portable in-place sed. Uses -i.bak for BSD compatibility. +# Restores the original file on failure. +sed_inplace() { + local file="$1" + shift + if ! sed -i.bak "$@" "$file"; then + mv "${file}.bak" "$file" 2>/dev/null || true + return 1 + fi + rm -f "${file}.bak" +} diff --git a/uspecs/u/scripts/conf.sh b/uspecs/u/scripts/conf.sh index 978f671..d867c41 100644 --- a/uspecs/u/scripts/conf.sh +++ b/uspecs/u/scripts/conf.sh @@ -299,11 +299,11 @@ show_operation_plan() { echo "" echo "Pull request:" - # Get PR info from pr.sh - local pr_output pr_remote default_branch target_repo_url pr_branch - if pr_output=$(bash "$script_dir/_lib/pr.sh" info 2>&1); then - pr_remote=$(echo "$pr_output" | grep '^pr_remote=' | cut -d= -f2) - default_branch=$(echo "$pr_output" | grep '^default_branch=' | cut -d= -f2) + local -A pr_info + local pr_remote="" default_branch="" target_repo_url="" pr_branch="" + if get_pr_info "$script_dir/_lib/pr.sh" pr_info 2>/dev/null; then + pr_remote="${pr_info[pr_remote]:-}" + default_branch="${pr_info[default_branch]:-}" target_repo_url=$(git remote get-url "$pr_remote" 2>/dev/null) # Use branch-safe version string for PR branch name @@ -316,8 +316,7 @@ show_operation_plan() { echo " Base branch: $default_branch" echo " PR branch: $pr_branch" else - echo " Failed to determine PR details:" - echo "$pr_output" | sed 's/^/ /' + echo " Failed to determine PR details" fi fi echo "==========================================" @@ -357,12 +356,31 @@ replace_uspecs_u() { cp -r "$source_dir/uspecs/u" "$project_dir/uspecs/" } +upgrade_markers() { + local file="$1" + sed_inplace "$file" "s/<!-- uspecs:triggering_instructions:begin -->/<!-- uspecs:begin -->/g; s/<!-- uspecs:triggering_instructions:end -->/<!-- uspecs:end -->/g" +} + +# Check that both begin and end markers are present in a file. +# Returns 0 if both found, 1 otherwise. +has_markers() { + local file="$1" + local begin_marker="$2" + local end_marker="$3" + grep -q "$begin_marker" "$file" && grep -q "$end_marker" "$file" +} + inject_instructions() { local source_file="$1" local target_file="$2" - local begin_marker="<!-- uspecs:triggering_instructions:begin -->" - local end_marker="<!-- uspecs:triggering_instructions:end -->" + local begin_marker="<!-- uspecs:begin -->" + local end_marker="<!-- uspecs:end -->" + + # Upgrade old markers in target first, so we always work with new markers below + if [[ -f "$target_file" ]]; then + upgrade_markers "$target_file" + fi if [[ ! -f "$source_file" ]]; then echo "Warning: Source file not found: $source_file" >&2 @@ -387,7 +405,7 @@ inject_instructions() { return 0 fi - if ! grep -q "$begin_marker" "$target_file" || ! grep -q "$end_marker" "$target_file"; then + if ! has_markers "$target_file" "$begin_marker" "$end_marker"; then { echo "" cat "$temp_extract" @@ -400,7 +418,7 @@ inject_instructions() { sed "/$begin_marker/,\$d" "$target_file" > "$temp_output" cat "$temp_extract" >> "$temp_output" sed "1,/$end_marker/d" "$target_file" >> "$temp_output" - mv "$temp_output" "$target_file" + cat "$temp_output" > "$target_file" } remove_instructions() { @@ -410,17 +428,16 @@ remove_instructions() { return 0 fi - local begin_marker="<!-- uspecs:triggering_instructions:begin -->" - local end_marker="<!-- uspecs:triggering_instructions:end -->" + local begin_marker="<!-- uspecs:begin -->" + local end_marker="<!-- uspecs:end -->" + + upgrade_markers "$target_file" - if ! grep -q "$begin_marker" "$target_file" || ! grep -q "$end_marker" "$target_file"; then + if ! has_markers "$target_file" "$begin_marker" "$end_marker"; then return 0 fi - local temp_output - temp_output=$(create_temp_file) - sed "/$begin_marker/,/$end_marker/d" "$target_file" > "$temp_output" - mv "$temp_output" "$target_file" + sed_inplace "$target_file" "/$begin_marker/,/$end_marker/d" } write_metadata() { @@ -891,11 +908,9 @@ cmd_im() { local timestamp timestamp=$(get_timestamp) - local temp_metadata - temp_metadata=$(create_temp_file) - sed "s/^invocation_methods: .*/invocation_methods: [$new_methods_str]/" "$metadata_file" | \ - sed "s/^modified_at: .*/modified_at: $timestamp/" > "$temp_metadata" - mv "$temp_metadata" "$metadata_file" + sed_inplace "$metadata_file" \ + -e "s/^invocation_methods: .*/invocation_methods: [$new_methods_str]/" \ + -e "s/^modified_at: .*/modified_at: $timestamp/" echo "" echo "Invocation methods updated successfully!" diff --git a/uspecs/u/scripts/uspecs.sh b/uspecs/u/scripts/uspecs.sh index 6fde20b..1b6b935 100644 --- a/uspecs/u/scripts/uspecs.sh +++ b/uspecs/u/scripts/uspecs.sh @@ -5,7 +5,10 @@ set -Eeuo pipefail # # Usage: # uspecs change new <change-name> [--issue-url <url>] [--branch] -# uspecs change archive <change-folder-name> +# uspecs change archive <change-folder-name> [-d] +# uspecs pr mergedef +# uspecs pr create --title <title> [--body <body>] +# uspecs diff specs # # change new: # Creates Change Folder and change.md with frontmatter: @@ -17,9 +20,21 @@ set -Eeuo pipefail # Creates git branch (if --branch provided and git repository exists) # Prints: <relative-path-to-change-folder> (e.g. uspecs/changes/2602201746-my-change) # -# change archive: +# change archive [-d]: # Archives change folder to <changes-folder>/archive/yymm/ymdHM-<change-name> # Adds archived_at metadata and updates folder date prefix +# -d: commit and push staged changes, checkout default branch, delete branch and refs +# Requires git repository, clean working tree, PR branch (ending with --pr) +# +# pr mergedef: +# Validates preconditions and merges pr_remote/default_branch into the current branch. +# +# pr create --title <title> --body <body>: +# Creates a PR from the current change branch (delegates to _lib/pr.sh changepr). +# Body can be passed via --body or piped via stdin. +# +# diff specs: +# Outputs git diff of the specs folder between HEAD and pr_remote/default_branch. error() { echo "Error: $1" >&2 @@ -30,9 +45,15 @@ get_timestamp() { date -u +"%Y-%m-%dT%H:%M:%SZ" } +is_git_repo() { + local dir="$1" + (cd "$dir" && git rev-parse --git-dir > /dev/null 2>&1) +} + get_baseline() { - if git rev-parse --git-dir > /dev/null 2>&1; then - git rev-parse HEAD 2>/dev/null || echo "" + local project_dir="$1" + if is_git_repo "$project_dir"; then + (cd "$project_dir" && git rev-parse HEAD 2>/dev/null) || echo "" else echo "" fi @@ -58,20 +79,48 @@ extract_change_name() { move_folder() { local source="$1" local destination="$2" - if git rev-parse --git-dir > /dev/null 2>&1; then - git mv "$source" "$destination" 2>/dev/null || mv "$source" "$destination" + local project_dir="${3:-}" + local check_dir="${project_dir:-$PWD}" + if is_git_repo "$check_dir"; then + if [[ -n "$project_dir" ]]; then + local rel_src="${source#"$project_dir/"}" + local rel_dst="${destination#"$project_dir/"}" + (cd "$project_dir" && git mv "$rel_src" "$rel_dst" 2>/dev/null) || mv "$source" "$destination" + else + git mv "$source" "$destination" 2>/dev/null || mv "$source" "$destination" + fi else mv "$source" "$destination" fi } +get_script_dir() { + cd "$(dirname "${BASH_SOURCE[0]}")" && pwd +} + +# shellcheck source=_lib/utils.sh +source "$(get_script_dir)/_lib/utils.sh" + get_project_dir() { local script_dir - script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + script_dir=$(get_script_dir) # scripts/ -> u/ -> uspecs/ -> project root cd "$script_dir/../../.." && pwd } +cmd_status_ispr() { + local project_dir + project_dir=$(get_project_dir) + if ! is_git_repo "$project_dir"; then + return 0 + fi + local branch + branch=$(cd "$project_dir" && git branch --show-current 2>&1) + if [[ "$branch" == *"--pr" ]]; then + echo "yes" + fi +} + read_conf_param() { local param_name="$1" local conf_file @@ -159,7 +208,7 @@ cmd_change_new() { local registered_at baseline registered_at=$(get_timestamp) - baseline=$(get_baseline) + baseline=$(get_baseline "$project_dir") local frontmatter="---"$'\n' frontmatter+="registered_at: $registered_at"$'\n' @@ -178,8 +227,8 @@ cmd_change_new() { printf '%s\n' "$frontmatter" > "$change_folder/change.md" if [ -n "$create_branch" ]; then - if git rev-parse --git-dir > /dev/null 2>&1; then - if ! git checkout -b "$change_name" 2>/dev/null; then + if is_git_repo "$project_dir"; then + if ! (cd "$project_dir" && git checkout -b "$change_name" 2>&1); then echo "Warning: Failed to create branch '$change_name'" >&2 fi else @@ -226,17 +275,34 @@ convert_links_to_relative() { # Add ../../ prefix to paths starting with ../ # ](../ -> ](../../../ - if ! sed -i.bak -E 's#\]\(\.\./#](../../../#g' "$file"; then + if ! sed_inplace "$file" -E 's#\]\(\.\./#](../../../#g'; then error "Failed to convert links in file: $file" fi - rm -f "${file}.bak" done <<< "$md_files" return 0 } cmd_change_archive() { - local folder_name="$1" + local folder_name="" + local delete_branch="" + + while [[ $# -gt 0 ]]; do + case "$1" in + -d) + delete_branch="1" + shift + ;; + *) + if [ -z "$folder_name" ]; then + folder_name="$1" + shift + else + error "Unknown argument: $1" + fi + ;; + esac + done if [ -z "$folder_name" ]; then error "change-folder-name is required" @@ -248,6 +314,15 @@ cmd_change_archive() { local project_dir project_dir=$(get_project_dir) + local is_git="" + if is_git_repo "$project_dir"; then + is_git="1" + fi + + if [ -n "$delete_branch" ] && [ -z "$is_git" ]; then + error "-d requires a git repository" + fi + local changes_folder="$project_dir/$changes_folder_rel" local path_to_change_folder="$changes_folder/$folder_name" @@ -260,7 +335,7 @@ cmd_change_archive() { error "change.md not found in folder: $path_to_change_folder" fi - if [[ "$folder_name" == *archive* ]]; then + if [[ "$folder_name" == archive/* ]]; then error "Folder is already in archive: $folder_name" fi @@ -277,6 +352,54 @@ cmd_change_archive() { exit 1 fi + local change_name + change_name=$(extract_change_name "$folder_name") + + if [ -n "$delete_branch" ] && [ -n "$is_git" ]; then + local branch_name + branch_name=$(cd "$project_dir" && git symbolic-ref --short HEAD 2>/dev/null || echo "") + if [ -z "$branch_name" ]; then + error "-d requires a named branch (HEAD is detached)" + fi + + local -A pr_info + local pr_sh + pr_sh="$(get_script_dir)/_lib/pr.sh" + if ! get_pr_info "$pr_sh" pr_info; then + error "-d requires pr.sh info to be available (remote reachable?)" + fi + local default_branch="${pr_info[default_branch]:-}" + + # a) no uncommitted changes + local git_status + git_status=$(cd "$project_dir" && git status --porcelain) + if [ -n "$git_status" ]; then + error "-d requires a clean working tree (uncommitted changes found)" + fi + + # b) branch must not be the default branch + if [ "$branch_name" = "$default_branch" ]; then + error "-d cannot be used on the default branch '$default_branch'" + fi + + # c) remote tracking ref must exist + if ! (cd "$project_dir" && git rev-parse --verify "refs/remotes/origin/$branch_name" > /dev/null 2>&1); then + error "No remote tracking ref found for '$branch_name'. Push the branch first." + fi + + # d) no divergence + local behind + behind=$(cd "$project_dir" && git rev-list --count "HEAD..origin/$branch_name") + if [ "$behind" -gt 0 ]; then + error "Branch '$branch_name' is behind origin by $behind commit(s). Pull or rebase first." + fi + + # e) branch must be a PR branch + if [[ "$branch_name" != *--pr ]]; then + error "-d can only be used on a PR branch (must end with '--pr'): '$branch_name'" + fi + fi + local timestamp timestamp=$(get_timestamp) @@ -294,12 +417,12 @@ cmd_change_archive() { } next } + /^archived_at:/ { next } { print } ' "$change_file" > "$temp_file" - if mv "$temp_file" "$change_file"; then + if cat "$temp_file" > "$change_file"; then : # Success, continue else - rm -f "$temp_file" return 1 fi @@ -319,23 +442,36 @@ cmd_change_archive() { local archive_subfolder="$archive_folder/$yymm_prefix" mkdir -p "$archive_subfolder" - local change_name - change_name=$(extract_change_name "$folder_name") - local archive_path="$archive_subfolder/${date_prefix}-${change_name}" if [ -d "$archive_path" ]; then error "Archive folder already exists: $archive_path" fi - if git rev-parse --git-dir > /dev/null 2>&1; then - git add "$path_to_change_folder" + if [ -n "$is_git" ]; then + local rel_change_folder="${path_to_change_folder#"$project_dir/"}" + (cd "$project_dir" && git add "$rel_change_folder") + fi + + move_folder "$path_to_change_folder" "$archive_path" "$project_dir" + + if [ -n "$is_git" ]; then + local rel_archive_path="${archive_path#"$project_dir/"}" + (cd "$project_dir" && git add "$rel_archive_path") fi - move_folder "$path_to_change_folder" "$archive_path" + if [ -n "$delete_branch" ] && [ -n "$is_git" ]; then + (cd "$project_dir" && git commit -m "archive $rel_change_folder to $rel_archive_path" 2>&1) + (cd "$project_dir" && git push 2>&1) + + (cd "$project_dir" && git checkout "$default_branch" 2>&1) - if git rev-parse --git-dir > /dev/null 2>&1; then - git add "$archive_path" + if (cd "$project_dir" && git show-ref --verify --quiet "refs/heads/$branch_name"); then + (cd "$project_dir" && git branch -D "$branch_name" 2>&1) + else + echo "Warning: branch '$branch_name' not found, skipping branch deletion" >&2 + fi + (cd "$project_dir" && git branch -dr "origin/$branch_name") 2>/dev/null || true fi echo "Archived change: $changes_folder_rel/archive/$yymm_prefix/${date_prefix}-${change_name}" @@ -349,6 +485,9 @@ main() { local command="$1" shift + local lib_dir + lib_dir="$(get_script_dir)/_lib" + case "$command" in change) if [ $# -lt 1 ]; then @@ -369,6 +508,57 @@ main() { ;; esac ;; + pr) + if [ $# -lt 1 ]; then + error "Usage: uspecs pr <subcommand> [args...]" + fi + local subcommand="$1" + shift + + case "$subcommand" in + mergedef) + "$lib_dir/pr.sh" mergedef "$@" + ;; + create) + "$lib_dir/pr.sh" changepr "$@" + ;; + *) + error "Unknown pr subcommand: $subcommand. Available: mergedef, create" + ;; + esac + ;; + diff) + if [ $# -lt 1 ]; then + error "Usage: uspecs diff <target>" + fi + local target="$1" + shift + + case "$target" in + specs) + "$lib_dir/pr.sh" diff specs "$@" + ;; + *) + error "Unknown diff target: $target. Available: specs" + ;; + esac + ;; + status) + if [ $# -lt 1 ]; then + error "Usage: uspecs status <subcommand> [args...]" + fi + local subcommand="$1" + shift + + case "$subcommand" in + ispr) + cmd_status_ispr "$@" + ;; + *) + error "Unknown status subcommand: $subcommand. Available: ispr" + ;; + esac + ;; *) error "Unknown command: $command" ;; diff --git a/uspecs/u/templates/tmpl-fd.md b/uspecs/u/templates/tmpl-fd.md index 56732f4..a29f4a4 100644 --- a/uspecs/u/templates/tmpl-fd.md +++ b/uspecs/u/templates/tmpl-fd.md @@ -4,6 +4,9 @@ Rules: +- Create very concise scenarios +- Focus on user-facing behavior (what the user observes), not internal implementation steps +- Place validation errors, failures, and error recovery under `Rule: Edge cases` - Prefer Scenario Outlines with Examples tables over multiple similar Scenarios - Use data tables in steps for inline structured data @@ -29,6 +32,14 @@ Feature: Payment processing | Cash | true | 50.00 | 49.00 | succeed | | Cash | true | 50.00 | 75.00 | be rejected with "Exceeds maximum limit" | | Credit Card | false | 100.00 | 50.00 | be rejected with "Method not available" | + + Rule: Edge cases + + Scenario: Waiter processes payment with no payment methods configured + Given no payment methods are configured for the location + When Waiter attempts to process a payment + Then error "No payment methods available" is displayed + And payment is not created ``` ## Requirements File (*reqs.md) template diff --git a/uspecs/u/templates/tmpl-how.md b/uspecs/u/templates/tmpl-how.md index 307af78..6de416a 100644 --- a/uspecs/u/templates/tmpl-how.md +++ b/uspecs/u/templates/tmpl-how.md @@ -35,4 +35,4 @@ References: ## Further iterations (How File exists) -AI Agent decides which key elements to add based on existing How File content and Change Request context. Use format from `$templates_folder/tmpl-td.md`. Do not be exhaustive - cover only what matters most. +AI Agent decides which key elements to add based on existing How File content and Change Request context. Use format from `{templates_folder}/tmpl-td.md`. Do not be exhaustive - cover only what matters most. diff --git a/uspecs/u/templates/tmpl-impl.md b/uspecs/u/templates/tmpl-impl.md index c03ecba..ad8c716 100644 --- a/uspecs/u/templates/tmpl-impl.md +++ b/uspecs/u/templates/tmpl-impl.md @@ -20,11 +20,11 @@ Note: Sections (Functional design, Technical design, Construction) contain check ### Section: Functional design -Ref. `$templates_folder/tmpl-fd.md` for specification file formats. +Ref. `{templates_folder}/tmpl-fd.md` for specification file formats. ### Section: Technical design -Ref. `$templates_folder/tmpl-td.md` for specification file formats. +Ref. `{templates_folder}/tmpl-td.md` for specification file formats. ### Section: Provisioning and configuration @@ -72,9 +72,9 @@ Rules: - Use relative paths for both existing files and new files that don't exist yet - Technical design section - Reference existing architecture files (e.g., `../../specs/prod/apps/vvm-orch--arch.md`) when updating them - - Use templates from `$templates_folder/tmpl-td.md` for structure of new files + - Use templates from `{templates_folder}/tmpl-td.md` for structure of new files - Construction section - - If design sections exist, run `git diff <baseline> -- $specs_folder/` to identify exact spec changes (baseline from Change File frontmatter) + - If design sections exist, run `git diff <baseline> -- {specs_folder}/` to identify exact spec changes (baseline from Change File frontmatter) - List all non-specification files that need to be created or modified, not already covered by other sections - Includes source files, tests, documentation, scripts, configuration - any file changes - Optional grouping: when items span 3+ distinct dependency categories, group under `###` headers ordered by dependency (foundational changes first, dependent changes after) diff --git a/uspecs/u/templates/tmpl-pr.md b/uspecs/u/templates/tmpl-pr.md new file mode 100644 index 0000000..679513f --- /dev/null +++ b/uspecs/u/templates/tmpl-pr.md @@ -0,0 +1,23 @@ +# Template: Pull request + +## PR title and body + +Derived from the specs diff. `draft_title` is a concise summary of changes; `draft_body` is a concise description in max three phrases. + +With issue reference: + +```text +pr_title: [{issue_id}] {draft_title} +pr_body: [{issue_id}]({issue_url}) {draft_title} + + {draft_body} +``` + +Without issue reference: + +```text +pr_title: {draft_title} +pr_body: {draft_title} + + {draft_body} +``` diff --git a/uspecs/u/uspecs.yml b/uspecs/u/uspecs.yml index 7178b17..c10e2d8 100644 --- a/uspecs/u/uspecs.yml +++ b/uspecs/u/uspecs.yml @@ -3,6 +3,6 @@ version: 0.1.0-a0 invocation_methods: [nlia] installed_at: 2026-02-21T18:48:11Z -modified_at: 2026-02-21T18:48:11Z -commit: 97c4e5cb605baeec49bddcafdbcb0e6553d23f59 -commit_timestamp: 2026-02-21T16:16:19Z +modified_at: 2026-02-24T13:43:06Z +commit: 3b41fb50b26de03cbc8e620f1de4ab2531f1ef28 +commit_timestamp: 2026-02-24T13:32:43Z