diff --git a/.agents/scripts/issue-sync-helper.sh b/.agents/scripts/issue-sync-helper.sh new file mode 100755 index 000000000..afea3cfa9 --- /dev/null +++ b/.agents/scripts/issue-sync-helper.sh @@ -0,0 +1,1182 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2155 +# ============================================================================= +# aidevops Issue Sync Helper +# ============================================================================= +# Bi-directional sync between TODO.md/PLANS.md and GitHub issues. +# Composes rich issue bodies with subtasks, plan context, and PRD links. +# +# Usage: issue-sync-helper.sh [command] [options] +# +# Commands: +# push [tNNN] Create/update GitHub issues from TODO.md tasks +# enrich [tNNN] Update existing issue bodies with full context +# pull Sync GitHub issue refs back to TODO.md +# close [tNNN] Close GitHub issue when TODO.md task is [x] +# status Show sync drift between TODO.md and GitHub +# parse [tNNN] Parse and display task context (dry-run) +# help Show this help message +# +# Options: +# --repo SLUG Override repo slug (default: auto-detect from git remote) +# --dry-run Show what would be done without making changes +# --verbose Show detailed output +# +# Part of aidevops framework: https://aidevops.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit +source "${SCRIPT_DIR}/shared-constants.sh" + +# ============================================================================= +# Configuration +# ============================================================================= + +VERBOSE="${VERBOSE:-false}" +DRY_RUN="${DRY_RUN:-false}" +REPO_SLUG="" + +# ============================================================================= +# Utility Functions +# ============================================================================= + +log_verbose() { + if [[ "$VERBOSE" == "true" ]]; then + print_info "$1" + fi + return 0 +} + +# Find project root (contains TODO.md) +find_project_root() { + local dir="$PWD" + while [[ "$dir" != "/" ]]; do + if [[ -f "$dir/TODO.md" ]]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + print_error "No TODO.md found in directory tree" + return 1 +} + +# Detect repo slug from git remote +detect_repo_slug() { + local project_root="$1" + local slug + local remote_url + remote_url=$(git -C "$project_root" remote get-url origin 2>/dev/null || echo "") + # Handle both HTTPS (github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git) + remote_url="${remote_url%.git}" # Strip .git suffix + slug=$(echo "$remote_url" | sed -E 's|.*[:/]([^/]+/[^/]+)$|\1|' || echo "") + if [[ -z "$slug" ]]; then + print_error "Could not detect GitHub repo slug from git remote" + return 1 + fi + echo "$slug" + return 0 +} + +# Verify gh CLI is available and authenticated +verify_gh_cli() { + if ! command -v gh &>/dev/null; then + print_error "gh CLI not installed. Install with: brew install gh" + return 1 + fi + if ! gh auth status &>/dev/null 2>&1; then + print_error "gh CLI not authenticated. Run: gh auth login" + return 1 + fi + return 0 +} + +# ============================================================================= +# TODO.md Parser +# ============================================================================= + +# Parse a single task line from TODO.md +# Returns structured data as key=value pairs +parse_task_line() { + local line="$1" + + # Extract checkbox status + local status="open" + if echo "$line" | grep -qE '^\s*- \[x\]'; then + status="completed" + elif echo "$line" | grep -qE '^\s*- \[-\]'; then + status="declined" + fi + + # Extract task ID + local task_id + task_id=$(echo "$line" | grep -oE 't[0-9]+(\.[0-9]+)*' | head -1 || echo "") + + # Extract description (between task ID and first #tag or ~estimate or →) + local description + description=$(echo "$line" | sed -E 's/^\s*- \[.\] t[0-9]+(\.[0-9]+)* //' | sed -E 's/ (#[a-z]|~[0-9]|→ |logged:|started:|completed:|ref:|actual:|blocked-by:).*//' || echo "") + + # Extract tags + local tags + tags=$(echo "$line" | grep -oE '#[a-z][a-z0-9-]*' | tr '\n' ',' | sed 's/,$//' || echo "") + + # Extract estimate + local estimate + estimate=$(echo "$line" | grep -oE '~[0-9]+[hmd](\s*\(ai:[^)]+\))?' | head -1 || echo "") + + # Extract plan link + local plan_link + plan_link=$(echo "$line" | grep -oE '→ \[todo/PLANS\.md#[^]]+\]' | sed 's/→ \[//' | sed 's/\]//' || echo "") + + # Extract existing GH ref + local gh_ref + gh_ref=$(echo "$line" | grep -oE 'ref:GH#[0-9]+' | head -1 | sed 's/ref:GH#//' || echo "") + + # Extract logged date + local logged + logged=$(echo "$line" | grep -oE 'logged:[0-9-]+' | sed 's/logged://' || echo "") + + echo "task_id=$task_id" + echo "status=$status" + echo "description=$description" + echo "tags=$tags" + echo "estimate=$estimate" + echo "plan_link=$plan_link" + echo "gh_ref=$gh_ref" + echo "logged=$logged" + return 0 +} + +# Extract a task and all its subtasks + notes from TODO.md +# Returns the full block of text for a given task ID +extract_task_block() { + local task_id="$1" + local todo_file="$2" + + local in_block=false + local block="" + local task_indent=-1 + + while IFS= read -r line; do + # Check if this is the target task line + if [[ "$in_block" == "false" ]] && echo "$line" | grep -qE "^\s*- \[.\] ${task_id} "; then + in_block=true + block="$line" + # Calculate indent level + task_indent=$(echo "$line" | sed -E 's/[^ ].*//' | wc -c) + task_indent=$((task_indent - 1)) + continue + fi + + if [[ "$in_block" == "true" ]]; then + # Check if we've hit the next task at same or lower indent + local current_indent + current_indent=$(echo "$line" | sed -E 's/[^ ].*//' | wc -c) + current_indent=$((current_indent - 1)) + + # Empty lines within block are kept + if [[ -z "${line// /}" ]]; then + break + fi + + # If indent is <= task indent and it's a new task, we're done + if [[ $current_indent -le $task_indent ]] && echo "$line" | grep -qE '^\s*- \[.\] t[0-9]'; then + break + fi + + # If indent is <= task indent and it's not a subtask/notes line, we're done + if [[ $current_indent -le $task_indent ]] && ! echo "$line" | grep -qE '^\s*- '; then + break + fi + + block="$block"$'\n'"$line" + fi + done < "$todo_file" + + echo "$block" + return 0 +} + +# Extract subtasks from a task block +extract_subtasks() { + local block="$1" + # Skip the first line (parent task), get indented subtask lines + echo "$block" | tail -n +2 | grep -E '^\s+- \[.\] t[0-9]' || true + return 0 +} + +# Extract Notes from a task block +extract_notes() { + local block="$1" + echo "$block" | grep -E '^\s+- Notes:' | sed 's/^\s*- Notes: //' || true + return 0 +} + +# ============================================================================= +# PLANS.md Parser +# ============================================================================= + +# Extract a plan section from PLANS.md given an anchor +extract_plan_section() { + local plan_link="$1" + local project_root="$2" + + if [[ -z "$plan_link" ]]; then + return 0 + fi + + local plans_file="$project_root/todo/PLANS.md" + if [[ ! -f "$plans_file" ]]; then + log_verbose "PLANS.md not found at $plans_file" + return 0 + fi + + # Convert anchor to heading text for matching + # e.g., "todo/PLANS.md#2026-02-08-git-issues-bi-directional-sync" + local anchor + anchor="${plan_link#todo/PLANS.md#}" + + # Find the heading that matches the anchor + local in_section=false + local section="" + local heading_level=0 + + while IFS= read -r line; do + # Check for heading match + if [[ "$in_section" == "false" ]]; then + # Generate anchor from heading: lowercase, spaces to hyphens, strip special chars + local line_anchor + line_anchor=$(echo "$line" | sed -E 's/^#+\s+//' | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9 -]//g' | sed -E 's/ /-/g') + + if [[ "$line_anchor" == "$anchor" ]] || echo "$line_anchor" | grep -qF "$anchor"; then + in_section=true + heading_level=$(echo "$line" | grep -oE '^#+' | wc -c) + heading_level=$((heading_level - 1)) + section="$line" + continue + fi + fi + + if [[ "$in_section" == "true" ]]; then + # Check if we've hit the next heading at same or higher level + if echo "$line" | grep -qE '^#{1,'"$heading_level"'} [^#]'; then + break + fi + section="$section"$'\n'"$line" + fi + done < "$plans_file" + + echo "$section" + return 0 +} + +# Extract just the Purpose section from a plan +extract_plan_purpose() { + local plan_section="$1" + local in_purpose=false + local purpose="" + + while IFS= read -r line; do + if echo "$line" | grep -qE '^####\s+Purpose'; then + in_purpose=true + continue + fi + if [[ "$in_purpose" == "true" ]]; then + if echo "$line" | grep -qE '^####\s+'; then + break + fi + purpose="$purpose"$'\n'"$line" + fi + done <<< "$plan_section" + + echo "$purpose" | sed '/^$/d' | head -20 + return 0 +} + +# Extract the Decision Log from a plan +extract_plan_decisions() { + local plan_section="$1" + local in_decisions=false + local decisions="" + + while IFS= read -r line; do + if echo "$line" | grep -qE '^####\s+Decision Log'; then + in_decisions=true + continue + fi + if [[ "$in_decisions" == "true" ]]; then + if echo "$line" | grep -qE '^####\s+'; then + break + fi + # Skip TOON blocks + if echo "$line" | grep -qE '^