diff --git a/README.md b/README.md index a27b99a6a..f871c0d0f 100644 --- a/README.md +++ b/README.md @@ -215,13 +215,12 @@ Specialized agents implement tasks while maintaining progress updates and an aud ### Epic Commands - `/pm:epic-decompose` - Break epic into task files -- `/pm:epic-sync` - Push epic and tasks to GitHub +- `/pm:epic-sync` - Bidirectional sync between local epics/tasks and GitHub issues - `/pm:epic-oneshot` - Decompose and sync in one command - `/pm:epic-list` - List all epics - `/pm:epic-show` - Display epic and its tasks - `/pm:epic-close` - Mark epic as complete - `/pm:epic-edit` - Edit epic details -- `/pm:epic-refresh` - Update epic progress from tasks ### Issue Commands - `/pm:issue-show` - Display issue and sub-issues diff --git a/ccpm/agents/test-runner.md b/ccpm/agents/test-runner.md index cb6a3f467..1e04f7397 100644 --- a/ccpm/agents/test-runner.md +++ b/ccpm/agents/test-runner.md @@ -6,7 +6,7 @@ model: inherit color: blue --- -You are an expert test execution and analysis specialist for the MUXI Runtime system. Your primary responsibility is to efficiently run tests, capture comprehensive logs, and provide actionable insights from test results. +You are an expert test execution and analysis specialist. Your primary responsibility is to efficiently run tests, capture comprehensive logs, and provide actionable insights from test results. ## Core Responsibilities diff --git a/ccpm/ccpm.config b/ccpm/ccpm.config index e4a6fa478..85839ea49 100644 --- a/ccpm/ccpm.config +++ b/ccpm/ccpm.config @@ -11,8 +11,15 @@ get_github_repo() { return 1 fi - # Handle both SSH and HTTPS, with or without .git extension - local repo=$(echo "$remote_url" | sed -E 's#^(https://|git@)github\.com[:/]##; s#\.git$##') + # Handle HTTPS, SSH, and SCP-style URLs + local repo="$remote_url" + # Remove various GitHub URL prefixes + repo=$(echo "$repo" | sed -E 's#^https://github\.com/##') + repo=$(echo "$repo" | sed -E 's#^git@github\.com:##') + repo=$(echo "$repo" | sed -E 's#^ssh://git@github\.com/##') + repo=$(echo "$repo" | sed -E 's#^ssh://github\.com/##') + # Remove .git suffix if present + repo=$(echo "$repo" | sed 's#\.git$##') # Validate format if [[ ! "$repo" =~ ^[^/]+/[^/]+$ ]]; then diff --git a/ccpm/commands/pm/clean.md b/ccpm/commands/pm/clean.md index 58a88e360..435ea1a74 100644 --- a/ccpm/commands/pm/clean.md +++ b/ccpm/commands/pm/clean.md @@ -1,5 +1,5 @@ --- -allowed-tools: Bash, Read, Write, LS +allowed-tools: Bash --- # Clean @@ -8,95 +8,34 @@ Clean up completed work and archive old epics. ## Usage ``` -/pm:clean [--dry-run] +/pm:clean [--dry-run] [--verbose] ``` Options: - `--dry-run` - Show what would be cleaned without doing it +- `--verbose` - Show detailed output during cleanup ## Instructions -### 1. Identify Completed Epics +Run the system cleanup script: -Find epics with: -- `status: completed` in frontmatter -- All tasks closed -- Last update > 30 days ago - -### 2. Identify Stale Work - -Find: -- Progress files for closed issues -- Update directories for completed work -- Orphaned task files (epic deleted) -- Empty directories - -### 3. Show Cleanup Plan - -``` -๐Ÿงน Cleanup Plan - -Completed Epics to Archive: - {epic_name} - Completed {days} days ago - {epic_name} - Completed {days} days ago - -Stale Progress to Remove: - {count} progress files for closed issues - -Empty Directories: - {list_of_empty_dirs} - -Space to Recover: ~{size}KB - -{If --dry-run}: This is a dry run. No changes made. -{Otherwise}: Proceed with cleanup? (yes/no) -``` - -### 4. Execute Cleanup - -If user confirms: - -**Archive Epics:** ```bash -mkdir -p .claude/epics/.archived -mv .claude/epics/{completed_epic} .claude/epics/.archived/ -``` - -**Remove Stale Files:** -- Delete progress files for closed issues > 30 days -- Remove empty update directories -- Clean up orphaned files - -**Create Archive Log:** -Create `.claude/epics/.archived/archive-log.md`: -```markdown -# Archive Log - -## {current_date} -- Archived: {epic_name} (completed {date}) -- Removed: {count} stale progress files -- Cleaned: {count} empty directories +bash ccpm/scripts/pm/clean.sh $ARGUMENTS ``` -### 5. Output - -``` -โœ… Cleanup Complete - -Archived: - {count} completed epics - -Removed: - {count} stale files - {count} empty directories - -Space recovered: {size}KB - -System is clean and organized. -``` +The cleanup script will: +1. Scan for completed epics (status: completed, all tasks closed, >30 days old) +2. Find stale progress files for closed issues +3. Identify empty directories +4. Show cleanup plan and ask for confirmation +5. Archive completed epics to `.claude/epics/.archived/` +6. Remove stale files and empty directories +7. Create detailed archive log +8. Report space recovered ## Important Notes -Always offer --dry-run to preview changes. -Never delete PRDs or incomplete work. -Keep archive log for history. \ No newline at end of file +- Always previews changes before making them +- Never deletes PRDs or incomplete work +- Maintains complete archive log for history +- Can be run safely with `--dry-run` to preview changes \ No newline at end of file diff --git a/ccpm/commands/pm/epic-close.md b/ccpm/commands/pm/epic-close.md index db2b18144..96a8ecc95 100644 --- a/ccpm/commands/pm/epic-close.md +++ b/ccpm/commands/pm/epic-close.md @@ -1,5 +1,5 @@ --- -allowed-tools: Bash, Read, Write, LS +allowed-tools: Bash --- # Epic Close @@ -8,62 +8,41 @@ Mark an epic as complete when all tasks are done. ## Usage ``` -/pm:epic-close +/pm:epic-close [--archive] ``` -## Instructions - -### 1. Verify All Tasks Complete - -Check all task files in `.claude/epics/$ARGUMENTS/`: -- Verify all have `status: closed` in frontmatter -- If any open tasks found: "โŒ Cannot close epic. Open tasks remain: {list}" - -### 2. Update Epic Status - -Get current datetime: `date -u +"%Y-%m-%dT%H:%M:%SZ"` - -Update epic.md frontmatter: -```yaml -status: completed -progress: 100% -updated: {current_datetime} -completed: {current_datetime} -``` +Options: +- `--archive` - Automatically archive epic after closing -### 3. Update PRD Status - -If epic references a PRD, update its status to "complete". +## Instructions -### 4. Close Epic on GitHub +Close the epic using the epic management script: -If epic has GitHub issue: ```bash -gh issue close {epic_issue_number} --comment "โœ… Epic completed - all tasks done" +bash ccpm/scripts/pm/close-epic.sh close $ARGUMENTS ``` -### 5. Archive Option - -Ask user: "Archive completed epic? (yes/no)" +The script will: +1. Verify all tasks in the epic are closed (exits with error if not) +2. Update epic status to completed with 100% progress and completion timestamp +3. Update linked PRD status to complete (if referenced) +4. Close the epic issue on GitHub with completion comment +5. Calculate and display epic duration +6. Offer to archive the completed epic (or auto-archive with --archive flag) +7. Create detailed archive summary if archived -If yes: -- Move epic directory to `.claude/epics/.archived/{epic_name}/` -- Create archive summary with completion date +## Alternative: Reopen Epic -### 6. Output +To reopen a closed epic for additional work: -``` -โœ… Epic closed: $ARGUMENTS - Tasks completed: {count} - Duration: {days_from_created_to_completed} - -{If archived}: Archived to .claude/epics/.archived/ - -Next epic: Run /pm:next to see priority work +```bash +bash ccpm/scripts/pm/close-epic.sh reopen $ARGUMENTS ``` ## Important Notes -Only close epics with all tasks complete. -Preserve all data when archiving. -Update related PRD status. \ No newline at end of file +- Only closes epics when all tasks are complete +- Automatically updates linked PRD status +- Preserves all data when archiving +- Creates detailed completion and archive logs +- Can unarchive and reopen epics if needed \ No newline at end of file diff --git a/ccpm/commands/pm/epic-merge.md b/ccpm/commands/pm/epic-merge.md index 114b8c070..2531577fa 100644 --- a/ccpm/commands/pm/epic-merge.md +++ b/ccpm/commands/pm/epic-merge.md @@ -28,50 +28,13 @@ Merge completed epic from worktree back to main branch. Navigate to worktree and check status: ```bash -cd ../epic-$ARGUMENTS - -# Check for uncommitted changes -if [[ $(git status --porcelain) ]]; then - echo "โš ๏ธ Uncommitted changes in worktree:" - git status --short - echo "Commit or stash changes before merging" - exit 1 -fi - -# Check branch status -git fetch origin -git status -sb +!bash ccpm/scripts/pm/epic-merge/validate-worktree.sh "$ARGUMENTS" ``` ### 2. Run Tests (Optional but Recommended) ```bash -# Look for test commands based on project type -if [ -f package.json ]; then - npm test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f pom.xml ]; then - mvn test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then - ./gradlew test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f composer.json ]; then - ./vendor/bin/phpunit || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f *.sln ] || [ -f *.csproj ]; then - dotnet test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f Cargo.toml ]; then - cargo test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f go.mod ]; then - go test ./... || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f Gemfile ]; then - bundle exec rspec || bundle exec rake test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f pubspec.yaml ]; then - flutter test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f Package.swift ]; then - swift test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f CMakeLists.txt ]; then - cd build && ctest || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -elif [ -f Makefile ]; then - make test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" -fi +!bash ccpm/scripts/pm/epic-merge/run-tests.sh ``` ### 3. Update Epic Documentation @@ -86,93 +49,28 @@ Update `.claude/epics/$ARGUMENTS/epic.md`: ### 4. Attempt Merge ```bash -# Return to main repository -cd {main-repo-path} - -# Ensure main is up to date -git checkout main -git pull origin main - -# Attempt merge -echo "Merging epic/$ARGUMENTS to main..." -git merge epic/$ARGUMENTS --no-ff -m "Merge epic: $ARGUMENTS - -Completed features: -$(cd .claude/epics/$ARGUMENTS && ls *.md | grep -E '^[0-9]+' | while read f; do - echo "- $(grep '^name:' $f | cut -d: -f2)" -done) - -Closes epic #$(grep 'github:' .claude/epics/$ARGUMENTS/epic.md | grep -oE '#[0-9]+')" +!bash ccpm/scripts/pm/epic-merge/execute-merge.sh "$ARGUMENTS" ``` ### 5. Handle Merge Conflicts If merge fails with conflicts: ```bash -# Check conflict status -git status - -echo " -โŒ Merge conflicts detected! - -Conflicts in: -$(git diff --name-only --diff-filter=U) - -Options: -1. Resolve manually: - - Edit conflicted files - - git add {files} - - git commit - -2. Abort merge: - git merge --abort - -3. Get help: - /pm:epic-resolve $ARGUMENTS - -Worktree preserved at: ../epic-$ARGUMENTS -" -exit 1 +!bash ccpm/scripts/pm/epic-merge/handle-conflicts.sh "$ARGUMENTS" ``` ### 6. Post-Merge Cleanup If merge succeeds: ```bash -# Push to remote -git push origin main - -# Clean up worktree -git worktree remove ../epic-$ARGUMENTS -echo "โœ… Worktree removed: ../epic-$ARGUMENTS" - -# Delete branch -git branch -d epic/$ARGUMENTS -git push origin --delete epic/$ARGUMENTS 2>/dev/null || true - -# Archive epic locally -mkdir -p .claude/epics/archived/ -mv .claude/epics/$ARGUMENTS .claude/epics/archived/ -echo "โœ… Epic archived: .claude/epics/archived/$ARGUMENTS" +!bash ccpm/scripts/pm/epic-merge/cleanup-worktree.sh "$ARGUMENTS" ``` ### 7. Update GitHub Issues Close related issues: ```bash -# Get issue numbers from epic -epic_issue=$(grep 'github:' .claude/epics/archived/$ARGUMENTS/epic.md | grep -oE '[0-9]+$') - -# Close epic issue -gh issue close $epic_issue -c "Epic completed and merged to main" - -# Close task issues -for task_file in .claude/epics/archived/$ARGUMENTS/[0-9]*.md; do - issue_num=$(grep 'github:' $task_file | grep -oE '[0-9]+$') - if [ ! -z "$issue_num" ]; then - gh issue close $issue_num -c "Completed in epic merge" - fi -done +!bash ccpm/scripts/pm/epic-merge/close-github-issues.sh "$ARGUMENTS" ``` ### 8. Final Output diff --git a/ccpm/commands/pm/epic-refresh.md b/ccpm/commands/pm/epic-refresh.md deleted file mode 100644 index 8f1e91655..000000000 --- a/ccpm/commands/pm/epic-refresh.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -allowed-tools: Read, Write, LS ---- - -# Epic Refresh - -Update epic progress based on task states. - -## Usage -``` -/pm:epic-refresh -``` - -## Instructions - -### 1. Count Task Status - -Scan all task files in `.claude/epics/$ARGUMENTS/`: -- Count total tasks -- Count tasks with `status: closed` -- Count tasks with `status: open` -- Count tasks with work in progress - -### 2. Calculate Progress - -``` -progress = (closed_tasks / total_tasks) * 100 -``` - -Round to nearest integer. - -### 3. Update GitHub Task List - -If epic has GitHub issue, sync task checkboxes: - -```bash -# Get epic issue number from epic.md frontmatter -epic_issue={extract_from_github_field} - -if [ ! -z "$epic_issue" ]; then - # Get current epic body - gh issue view $epic_issue --json body -q .body > /tmp/epic-body.md - - # For each task, check its status and update checkbox - for task_file in .claude/epics/$ARGUMENTS/[0-9]*.md; do - task_issue=$(grep 'github:' $task_file | grep -oE '[0-9]+$') - task_status=$(grep 'status:' $task_file | cut -d: -f2 | tr -d ' ') - - if [ "$task_status" = "closed" ]; then - # Mark as checked - sed -i "s/- \[ \] #$task_issue/- [x] #$task_issue/" /tmp/epic-body.md - else - # Ensure unchecked (in case manually checked) - sed -i "s/- \[x\] #$task_issue/- [ ] #$task_issue/" /tmp/epic-body.md - fi - done - - # Update epic issue - gh issue edit $epic_issue --body-file /tmp/epic-body.md -fi -``` - -### 4. Determine Epic Status - -- If progress = 0% and no work started: `backlog` -- If progress > 0% and < 100%: `in-progress` -- If progress = 100%: `completed` - -### 5. Update Epic - -Get current datetime: `date -u +"%Y-%m-%dT%H:%M:%SZ"` - -Update epic.md frontmatter: -```yaml -status: {calculated_status} -progress: {calculated_progress}% -updated: {current_datetime} -``` - -### 6. Output - -``` -๐Ÿ”„ Epic refreshed: $ARGUMENTS - -Tasks: - Closed: {closed_count} - Open: {open_count} - Total: {total_count} - -Progress: {old_progress}% โ†’ {new_progress}% -Status: {old_status} โ†’ {new_status} -GitHub: Task list updated โœ“ - -{If complete}: Run /pm:epic-close $ARGUMENTS to close epic -{If in progress}: Run /pm:next to see priority tasks -``` - -## Important Notes - -This is useful after manual task edits or GitHub sync. -Don't modify task files, only epic status. -Preserve all other frontmatter fields. \ No newline at end of file diff --git a/ccpm/commands/pm/epic-start.md b/ccpm/commands/pm/epic-start.md index 51628a494..7581bf98a 100644 --- a/ccpm/commands/pm/epic-start.md +++ b/ccpm/commands/pm/epic-start.md @@ -13,25 +13,10 @@ Launch parallel agents to work on epic tasks in a shared branch. ## Quick Check -1. **Verify epic exists:** - ```bash - test -f .claude/epics/$ARGUMENTS/epic.md || echo "โŒ Epic not found. Run: /pm:prd-parse $ARGUMENTS" - ``` - -2. **Check GitHub sync:** - Look for `github:` field in epic frontmatter. - If missing: "โŒ Epic not synced. Run: /pm:epic-sync $ARGUMENTS first" - -3. **Check for branch:** - ```bash - git branch -a | grep "epic/$ARGUMENTS" - ``` - -4. **Check for uncommitted changes:** - ```bash - git status --porcelain - ``` - If output is not empty: "โŒ You have uncommitted changes. Please commit or stash them before starting an epic" +Run preflight checks: +```bash +!bash ccpm/scripts/pm/epic-start/preflight-checks.sh "$ARGUMENTS" +``` ## Instructions @@ -40,24 +25,7 @@ Launch parallel agents to work on epic tasks in a shared branch. Follow `/rules/branch-operations.md`: ```bash -# Check for uncommitted changes -if [ -n "$(git status --porcelain)" ]; then - echo "โŒ You have uncommitted changes. Please commit or stash them before starting an epic." - exit 1 -fi - -# If branch doesn't exist, create it -if ! git branch -a | grep -q "epic/$ARGUMENTS"; then - git checkout main - git pull origin main - git checkout -b epic/$ARGUMENTS - git push -u origin epic/$ARGUMENTS - echo "โœ… Created branch: epic/$ARGUMENTS" -else - git checkout epic/$ARGUMENTS - git pull origin epic/$ARGUMENTS - echo "โœ… Using existing branch: epic/$ARGUMENTS" -fi +!bash ccpm/scripts/pm/epic-start/manage-branch.sh "$ARGUMENTS" ``` ### 2. Identify Ready Issues @@ -77,11 +45,8 @@ Categorize issues: For each ready issue without analysis: ```bash -# Check for analysis -if ! test -f .claude/epics/$ARGUMENTS/{issue}-analysis.md; then - echo "Analyzing issue #{issue}..." - # Run analysis (inline or via Task tool) -fi +# Check for analysis (replace {issue} with actual issue number) +!bash ccpm/scripts/pm/epic-start/check-analysis.sh "$ARGUMENTS" "{issue}" ``` ### 4. Launch Parallel Agents @@ -128,48 +93,16 @@ Task: ### 5. Track Active Agents -Create/update `.claude/epics/$ARGUMENTS/execution-status.md`: - -```markdown ---- -started: {datetime} -branch: epic/$ARGUMENTS ---- - -# Execution Status - -## Active Agents -- Agent-1: Issue #1234 Stream A (Database) - Started {time} -- Agent-2: Issue #1234 Stream B (API) - Started {time} -- Agent-3: Issue #1235 Stream A (UI) - Started {time} - -## Queued Issues -- Issue #1236 - Waiting for #1234 -- Issue #1237 - Waiting for #1235 - -## Completed -- {None yet} +Create/update execution status file: +```bash +!bash ccpm/scripts/pm/epic-start/create-execution-status.sh "$ARGUMENTS" ``` ### 6. Monitor and Coordinate Set up monitoring: ```bash -echo " -Agents launched successfully! - -Monitor progress: - /pm:epic-status $ARGUMENTS - -View branch changes: - git status - -Stop all agents: - /pm:epic-stop $ARGUMENTS - -Merge when complete: - /pm:epic-merge $ARGUMENTS -" +!bash ccpm/scripts/pm/epic-start/setup-monitoring.sh "$ARGUMENTS" ``` ### 7. Handle Dependencies diff --git a/ccpm/commands/pm/epic-sync.md b/ccpm/commands/pm/epic-sync.md index 76b6d2227..7d7f296bb 100644 --- a/ccpm/commands/pm/epic-sync.md +++ b/ccpm/commands/pm/epic-sync.md @@ -2,465 +2,222 @@ allowed-tools: Bash, Read, Write, LS, Task --- -# Epic Sync +# Epic Sync 2.0 - Bidirectional Sync -Push epic and tasks to GitHub as issues. +Smart bidirectional epic sync that maintains perfect consistency between local files and GitHub issues. ## Usage ``` -/pm:epic-sync +/pm:epic-sync2 ``` +## Overview + +This command implements a bidirectional sync that: +- **Pulls from GitHub**: Fetches all epic/task issues and compares with local files +- **Uses timestamps**: `updated` dates determine source of truth (local vs remote) +- **Updates locally**: When GitHub issue is newer, updates local .md file +- **Updates GitHub**: When local is newer, updates GitHub issue content and status +- **Creates missing items**: GitHub issues โ†’ local files, or local files โ†’ GitHub issues +- **Progress reporting**: Posts progress comments to GitHub issues +- **Auto-closes**: Closes GitHub issues when locally marked as completed + ## Quick Check ```bash # Verify epic exists test -f .claude/epics/$ARGUMENTS/epic.md || echo "โŒ Epic not found. Run: /pm:prd-parse $ARGUMENTS" - -# Count task files -ls .claude/epics/$ARGUMENTS/*.md 2>/dev/null | grep -v epic.md | wc -l ``` -If no tasks found: "โŒ No tasks to sync. Run: /pm:epic-decompose $ARGUMENTS" - ## Instructions -### 0. Check Remote Repository - -Follow `/rules/github-operations.md` to ensure we're not syncing to the CCPM template: +### 0. Repository Protection Check ```bash # Check if remote origin is the CCPM template repository remote_url=$(git remote get-url origin 2>/dev/null || echo "") if [[ "$remote_url" == *"automazeio/ccpm"* ]] || [[ "$remote_url" == *"automazeio/ccpm.git"* ]]; then echo "โŒ ERROR: You're trying to sync with the CCPM template repository!" + echo "This repository is a template - you should NOT create issues here." echo "" - echo "This repository (automazeio/ccpm) is a template for others to use." - echo "You should NOT create issues or PRs here." - echo "" - echo "To fix this:" - echo "1. Fork this repository to your own GitHub account" - echo "2. Update your remote origin:" - echo " git remote set-url origin https://github.com/YOUR_USERNAME/YOUR_REPO.git" - echo "" - echo "Or if this is a new project:" - echo "1. Create a new repository on GitHub" - echo "2. Update your remote origin:" - echo " git remote set-url origin https://github.com/YOUR_USERNAME/YOUR_REPO.git" - echo "" - echo "Current remote: $remote_url" + echo "Please fork this repository or create your own project repository." exit 1 fi ``` -### 1. Create Epic Issue +### 1. Initialize Sync Environment -#### First, detect the GitHub repository: ```bash -# Get the current repository from git remote +echo "๐Ÿ”„ Starting bidirectional epic sync for: $ARGUMENTS" + +# Get repository information remote_url=$(git remote get-url origin 2>/dev/null || echo "") REPO=$(echo "$remote_url" | sed 's|.*github.com[:/]||' | sed 's|\.git$||') [ -z "$REPO" ] && REPO="user/repo" -echo "Creating issues in repository: $REPO" -``` +echo "๐Ÿ“ Repository: $REPO" -Strip frontmatter and prepare GitHub issue body: -```bash -# Extract content without frontmatter -sed '1,/^---$/d; 1,/^---$/d' .claude/epics/$ARGUMENTS/epic.md > /tmp/epic-body-raw.md - -# Remove "## Tasks Created" section and replace with Stats -awk ' - /^## Tasks Created/ { - in_tasks=1 - next - } - /^## / && in_tasks { - in_tasks=0 - # When we hit the next section after Tasks Created, add Stats - if (total_tasks) { - print "## Stats\n" - print "Total tasks: " total_tasks - print "Parallel tasks: " parallel_tasks " (can be worked on simultaneously)" - print "Sequential tasks: " sequential_tasks " (have dependencies)" - if (total_effort) print "Estimated total effort: " total_effort " hours" - print "" - } - } - /^Total tasks:/ && in_tasks { total_tasks = $3; next } - /^Parallel tasks:/ && in_tasks { parallel_tasks = $3; next } - /^Sequential tasks:/ && in_tasks { sequential_tasks = $3; next } - /^Estimated total effort:/ && in_tasks { - gsub(/^Estimated total effort: /, "") - total_effort = $0 - next - } - !in_tasks { print } - END { - # If we were still in tasks section at EOF, add stats - if (in_tasks && total_tasks) { - print "## Stats\n" - print "Total tasks: " total_tasks - print "Parallel tasks: " parallel_tasks " (can be worked on simultaneously)" - print "Sequential tasks: " sequential_tasks " (have dependencies)" - if (total_effort) print "Estimated total effort: " total_effort - } - } -' /tmp/epic-body-raw.md > /tmp/epic-body.md - -# Determine epic type (feature vs bug) from content -if grep -qi "bug\|fix\|issue\|problem\|error" /tmp/epic-body.md; then - epic_type="bug" -else - epic_type="feature" +# Verify GitHub CLI authentication +if ! gh auth status &>/dev/null; then + echo "โŒ GitHub CLI not authenticated. Run: gh auth login" + exit 1 fi -# Create epic issue with labels -epic_number=$(gh issue create \ - --repo "$REPO" \ - --title "Epic: $ARGUMENTS" \ - --body-file /tmp/epic-body.md \ - --label "epic,epic:$ARGUMENTS,$epic_type" \ - --json number -q .number) -``` - -Store the returned issue number for epic frontmatter update. - -### 2. Create Task Sub-Issues - -Check if gh-sub-issue is available: -```bash -if gh extension list | grep -q "yahsan2/gh-sub-issue"; then +# Check for sub-issue extension +if gh extension list 2>/dev/null | grep -q "yahsan2/gh-sub-issue"; then use_subissues=true + echo "๐Ÿ”ง Sub-issue extension available" else use_subissues=false - echo "โš ๏ธ gh-sub-issue not installed. Using fallback mode." + echo "โš ๏ธ Sub-issue extension not installed - using fallback mode" fi ``` -Count task files to determine strategy: -```bash -task_count=$(ls .claude/epics/$ARGUMENTS/[0-9][0-9][0-9].md 2>/dev/null | wc -l) -``` +### 2. Fetch All GitHub Issues for Epic -### For Small Batches (< 5 tasks): Sequential Creation +Execute the GitHub issues fetcher: ```bash -if [ "$task_count" -lt 5 ]; then - # Create sequentially for small batches - for task_file in .claude/epics/$ARGUMENTS/[0-9][0-9][0-9].md; do - [ -f "$task_file" ] || continue - - # Extract task name from frontmatter - task_name=$(grep '^name:' "$task_file" | sed 's/^name: *//') - - # Strip frontmatter from task content - sed '1,/^---$/d; 1,/^---$/d' "$task_file" > /tmp/task-body.md - - # Create sub-issue with labels - if [ "$use_subissues" = true ]; then - task_number=$(gh sub-issue create \ - --parent "$epic_number" \ - --title "$task_name" \ - --body-file /tmp/task-body.md \ - --label "task,epic:$ARGUMENTS" \ - --json number -q .number) - else - task_number=$(gh issue create \ - --repo "$REPO" \ - --title "$task_name" \ - --body-file /tmp/task-body.md \ - --label "task,epic:$ARGUMENTS" \ - --json number -q .number) - fi - - # Record mapping for renaming - echo "$task_file:$task_number" >> /tmp/task-mapping.txt - done - - # After creating all issues, update references and rename files - # This follows the same process as step 3 below -fi +!bash ccpm/scripts/pm/epic-sync/fetch-github-issues.sh $ARGUMENTS "$REPO" ``` -### For Larger Batches: Parallel Creation +### 3. Build Local File Inventory -```bash -if [ "$task_count" -ge 5 ]; then - echo "Creating $task_count sub-issues in parallel..." - - # Check if gh-sub-issue is available for parallel agents - if gh extension list | grep -q "yahsan2/gh-sub-issue"; then - subissue_cmd="gh sub-issue create --parent $epic_number" - else - subissue_cmd="gh issue create --repo \"$REPO\"" - fi - - # Batch tasks for parallel processing - # Spawn agents to create sub-issues in parallel with proper labels - # Each agent must use: --label "task,epic:$ARGUMENTS" -fi -``` +Build inventory of all local files with metadata: -Use Task tool for parallel creation: -```yaml -Task: - description: "Create GitHub sub-issues batch {X}" - subagent_type: "general-purpose" - prompt: | - Create GitHub sub-issues for tasks in epic $ARGUMENTS - Parent epic issue: #$epic_number - - Tasks to process: - - {list of 3-4 task files} - - For each task file: - 1. Extract task name from frontmatter - 2. Strip frontmatter using: sed '1,/^---$/d; 1,/^---$/d' - 3. Create sub-issue using: - - If gh-sub-issue available: - gh sub-issue create --parent $epic_number --title "$task_name" \ - --body-file /tmp/task-body.md --label "task,epic:$ARGUMENTS" - - Otherwise: - gh issue create --repo "$REPO" --title "$task_name" --body-file /tmp/task-body.md \ - --label "task,epic:$ARGUMENTS" - 4. Record: task_file:issue_number - - IMPORTANT: Always include --label parameter with "task,epic:$ARGUMENTS" - - Return mapping of files to issue numbers. -``` - -Consolidate results from parallel agents: ```bash -# Collect all mappings from agents -cat /tmp/batch-*/mapping.txt >> /tmp/task-mapping.txt - -# IMPORTANT: After consolidation, follow step 3 to: -# 1. Build old->new ID mapping -# 2. Update all task references (depends_on, conflicts_with) -# 3. Rename files with proper frontmatter updates +!bash ccpm/scripts/pm/epic-sync/build-local-inventory.sh $ARGUMENTS ``` -### 3. Rename Task Files and Update References +### 4. Compare and Plan Sync Actions -First, build a mapping of old numbers to new issue IDs: -```bash -# Create mapping from old task numbers (001, 002, etc.) to new issue IDs -> /tmp/id-mapping.txt -while IFS=: read -r task_file task_number; do - # Extract old number from filename (e.g., 001 from 001.md) - old_num=$(basename "$task_file" .md) - echo "$old_num:$task_number" >> /tmp/id-mapping.txt -done < /tmp/task-mapping.txt -``` +Analyze differences and plan sync actions: -Then rename files and update all references: ```bash -# Process each task file -while IFS=: read -r task_file task_number; do - new_name="$(dirname "$task_file")/${task_number}.md" - - # Read the file content - content=$(cat "$task_file") - - # Update depends_on and conflicts_with references - while IFS=: read -r old_num new_num; do - # Update arrays like [001, 002] to use new issue numbers - content=$(echo "$content" | sed "s/\b$old_num\b/$new_num/g") - done < /tmp/id-mapping.txt - - # Write updated content to new file - echo "$content" > "$new_name" - - # Remove old file if different from new - [ "$task_file" != "$new_name" ] && rm "$task_file" - - # Update github field in frontmatter - # Add the GitHub URL to the frontmatter - repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) - github_url="https://github.com/$repo/issues/$task_number" - - # Update frontmatter with GitHub URL and current timestamp - current_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - - # Use sed to update the github and updated fields - sed -i.bak "/^github:/c\github: $github_url" "$new_name" - sed -i.bak "/^updated:/c\updated: $current_date" "$new_name" - rm "${new_name}.bak" -done < /tmp/task-mapping.txt +!bash ccpm/scripts/pm/epic-sync/plan-sync-actions.sh $ARGUMENTS ``` -### 4. Update Epic with Task List (Fallback Only) +### 5. Execute Sync Actions - Update Local Files -If NOT using gh-sub-issue, add task list to epic: +Update local files from GitHub data: ```bash -if [ "$use_subissues" = false ]; then - # Get current epic body - gh issue view ${epic_number} --json body -q .body > /tmp/epic-body.md - - # Append task list - cat >> /tmp/epic-body.md << 'EOF' - - ## Tasks - - [ ] #${task1_number} ${task1_name} - - [ ] #${task2_number} ${task2_name} - - [ ] #${task3_number} ${task3_name} - EOF - - # Update epic issue - gh issue edit ${epic_number} --body-file /tmp/epic-body.md -fi +!bash ccpm/scripts/pm/epic-sync/sync-local-files.sh $ARGUMENTS ``` -With gh-sub-issue, this is automatic! - -### 5. Update Epic File +### 6. Execute Sync Actions - Update GitHub Issues -Update the epic file with GitHub URL, timestamp, and real task IDs: +Update GitHub issues from local data: -#### 5a. Update Frontmatter ```bash -# Get repo info -repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) -epic_url="https://github.com/$repo/issues/$epic_number" -current_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - -# Update epic frontmatter -sed -i.bak "/^github:/c\github: $epic_url" .claude/epics/$ARGUMENTS/epic.md -sed -i.bak "/^updated:/c\updated: $current_date" .claude/epics/$ARGUMENTS/epic.md -rm .claude/epics/$ARGUMENTS/epic.md.bak +!bash ccpm/scripts/pm/epic-sync/sync-github-issues.sh $ARGUMENTS "$REPO" "$use_subissues" ``` -#### 5b. Update Tasks Created Section -```bash -# Create a temporary file with the updated Tasks Created section -cat > /tmp/tasks-section.md << 'EOF' -## Tasks Created -EOF - -# Add each task with its real issue number -for task_file in .claude/epics/$ARGUMENTS/[0-9]*.md; do - [ -f "$task_file" ] || continue - - # Get issue number (filename without .md) - issue_num=$(basename "$task_file" .md) - - # Get task name from frontmatter - task_name=$(grep '^name:' "$task_file" | sed 's/^name: *//') - - # Get parallel status - parallel=$(grep '^parallel:' "$task_file" | sed 's/^parallel: *//') - - # Add to tasks section - echo "- [ ] #${issue_num} - ${task_name} (parallel: ${parallel})" >> /tmp/tasks-section.md -done - -# Add summary statistics -total_count=$(ls .claude/epics/$ARGUMENTS/[0-9]*.md 2>/dev/null | wc -l) -parallel_count=$(grep -l '^parallel: true' .claude/epics/$ARGUMENTS/[0-9]*.md 2>/dev/null | wc -l) -sequential_count=$((total_count - parallel_count)) - -cat >> /tmp/tasks-section.md << EOF - -Total tasks: ${total_count} -Parallel tasks: ${parallel_count} -Sequential tasks: ${sequential_count} -EOF - -# Replace the Tasks Created section in epic.md -# First, create a backup -cp .claude/epics/$ARGUMENTS/epic.md .claude/epics/$ARGUMENTS/epic.md.backup - -# Use awk to replace the section -awk ' - /^## Tasks Created/ { - skip=1 - while ((getline line < "/tmp/tasks-section.md") > 0) print line - close("/tmp/tasks-section.md") - } - /^## / && !/^## Tasks Created/ { skip=0 } - !skip && !/^## Tasks Created/ { print } -' .claude/epics/$ARGUMENTS/epic.md.backup > .claude/epics/$ARGUMENTS/epic.md - -# Clean up -rm .claude/epics/$ARGUMENTS/epic.md.backup -rm /tmp/tasks-section.md -``` +### 7. Post Progress Reports and Manage Issue States -### 6. Create Mapping File +Post progress comments and manage issue states: -Create `.claude/epics/$ARGUMENTS/github-mapping.md`: ```bash -# Create mapping file -cat > .claude/epics/$ARGUMENTS/github-mapping.md << EOF -# GitHub Issue Mapping - -Epic: #${epic_number} - https://github.com/${repo}/issues/${epic_number} - -Tasks: -EOF - -# Add each task mapping -for task_file in .claude/epics/$ARGUMENTS/[0-9]*.md; do - [ -f "$task_file" ] || continue - - issue_num=$(basename "$task_file" .md) - task_name=$(grep '^name:' "$task_file" | sed 's/^name: *//') - - echo "- #${issue_num}: ${task_name} - https://github.com/${repo}/issues/${issue_num}" >> .claude/epics/$ARGUMENTS/github-mapping.md -done - -# Add sync timestamp -echo "" >> .claude/epics/$ARGUMENTS/github-mapping.md -echo "Synced: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> .claude/epics/$ARGUMENTS/github-mapping.md +!bash ccpm/scripts/pm/epic-sync/post-progress-reports.sh $ARGUMENTS ``` -### 7. Create Worktree +### 8. Create Worktree and Update Mappings -Follow `/rules/worktree-operations.md` to create development worktree: +Ensure worktree exists and update mapping file: ```bash -# Ensure main is current -git checkout main -git pull origin main - -# Create worktree for epic -git worktree add ../epic-$ARGUMENTS -b epic/$ARGUMENTS - -echo "โœ… Created worktree: ../epic-$ARGUMENTS" +!bash ccpm/scripts/pm/epic-sync/worktree-and-mappings.sh $ARGUMENTS ``` -### 8. Output - -``` -โœ… Synced to GitHub - - Epic: #{epic_number} - {epic_title} - - Tasks: {count} sub-issues created - - Labels applied: epic, task, epic:{name} - - Files renamed: 001.md โ†’ {issue_id}.md - - References updated: depends_on/conflicts_with now use issue IDs - - Worktree: ../epic-$ARGUMENTS - -Next steps: - - Start parallel execution: /pm:epic-start $ARGUMENTS - - Or work on single issue: /pm:issue-start {issue_number} - - View epic: https://github.com/{owner}/{repo}/issues/{epic_number} -``` +### 9. Cleanup and Summary -## Error Handling - -Follow `/rules/github-operations.md` for GitHub CLI errors. - -If any issue creation fails: -- Report what succeeded -- Note what failed -- Don't attempt rollback (partial sync is fine) +```bash +# Get final stats from temp files +epic_number=$(cat /tmp/epic-sync/epic-number.txt 2>/dev/null || echo "TBD") +progress=$(cat /tmp/epic-sync/progress.txt 2>/dev/null || echo "0") +total_tasks=$(cat /tmp/epic-sync/total-tasks.txt 2>/dev/null || echo "0") +closed_tasks=$(cat /tmp/epic-sync/closed-tasks.txt 2>/dev/null || echo "0") +repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) -## Important Notes +echo "" +echo "๐ŸŽฏ Bidirectional Epic Sync Complete: $ARGUMENTS" +echo "==================================================" +echo "" +echo "๐Ÿ“Š **Final Status:**" +echo " Epic: #${epic_number} ($progress% complete)" +echo " Tasks: $closed_tasks completed / $total_tasks total" +echo "" +echo "๐Ÿ”„ **Sync Summary:**" +if [ -s /tmp/epic-sync/sync-actions.txt ]; then + updated_local=$(grep -c "^update_local:" /tmp/epic-sync/sync-actions.txt || echo 0) + updated_github=$(grep -c "^update_github:" /tmp/epic-sync/sync-actions.txt || echo 0) + created_local=$(grep -c "^create_local:" /tmp/epic-sync/sync-actions.txt || echo 0) + created_github=$(grep -c "^create_github:" /tmp/epic-sync/sync-actions.txt || echo 0) + + echo " ๐Ÿ“ฅ Local files updated: $updated_local" + echo " ๐Ÿ“ค GitHub issues updated: $updated_github" + echo " ๐Ÿ“ Local files created: $created_local" + echo " ๐Ÿ†• GitHub issues created: $created_github" +else + echo " โœ… Everything was already in perfect sync!" +fi -- Trust GitHub CLI authentication -- Don't pre-check for duplicates -- Update frontmatter only after successful creation -- Keep operations simple and atomic +echo "" +echo "๐Ÿ”— **Links:**" +echo " Epic: https://github.com/${repo}/issues/${epic_number}" +echo " Mapping: .claude/epics/$ARGUMENTS/github-mapping.md" +echo " Worktree: ../epic-$ARGUMENTS" +echo "" +echo "๐Ÿš€ **Next Steps:**" +echo " โ€ข Start development: /pm:epic-start $ARGUMENTS" +echo " โ€ข Work on specific task: /pm:issue-start " +echo " โ€ข Check progress: cat .claude/epics/$ARGUMENTS/github-mapping.md" + +# Cleanup temp files +rm -rf /tmp/epic-sync/ + +echo "" +echo "โœจ Bidirectional sync completed successfully!" +``` + +## Key Features + +### ๐Ÿ”„ **Bidirectional Sync** +- **GitHub โ†’ Local**: Updates local .md files when GitHub issues are newer +- **Local โ†’ GitHub**: Updates GitHub issues when local files are newer +- **Timestamp-based**: Uses `updated` fields to determine source of truth + +### ๐Ÿ†• **Orphan Handling** +- **Missing Local**: Creates local .md files from GitHub issues +- **Missing GitHub**: Creates GitHub issues from local .md files +- **Smart Detection**: Handles deleted/missing issues gracefully + +### ๐Ÿ“Š **Progress Management** +- **Auto-close**: Closes GitHub issues when locally marked as completed +- **Progress comments**: Posts regular sync updates to all issues +- **Epic tracking**: Updates epic progress based on task completion + +### ๐Ÿ”ง **Advanced Features** +- **Sub-issue support**: Uses gh-sub-issue extension when available +- **Status sync**: Keeps GitHub issue state in sync with local status +- **File renaming**: Renames local files to match GitHub issue numbers +- **Worktree management**: Creates/maintains development worktrees + +### โš™๏ธ **Modular Architecture** +- **Focused Scripts**: Each sync step is a separate, testable script +- **Reusable Components**: Scripts can be used independently or by other commands +- **Clear Separation**: Logic flow in markdown, implementation in scripts +- **Easy Maintenance**: Scripts have proper syntax highlighting and debugging + +## Script Components + +The sync process is powered by these modular scripts: + +- **`fetch-github-issues.sh`** - Fetches all GitHub issues for the epic +- **`build-local-inventory.sh`** - Builds inventory of local files with metadata +- **`plan-sync-actions.sh`** - Compares timestamps and plans sync actions +- **`sync-local-files.sh`** - Updates local files from GitHub data +- **`sync-github-issues.sh`** - Updates GitHub issues from local data +- **`post-progress-reports.sh`** - Posts progress comments and manages issue states +- **`worktree-and-mappings.sh`** - Creates worktrees and updates mapping files + +This replaces both `epic-sync.md` and `epic-refresh.md` with a single, comprehensive bidirectional sync command. \ No newline at end of file diff --git a/ccpm/commands/pm/issue-close.md b/ccpm/commands/pm/issue-close.md index a7b96f21f..38a1fac11 100644 --- a/ccpm/commands/pm/issue-close.md +++ b/ccpm/commands/pm/issue-close.md @@ -1,5 +1,5 @@ --- -allowed-tools: Bash, Read, Write, LS +allowed-tools: Bash --- # Issue Close @@ -13,90 +13,29 @@ Mark an issue as complete and close it on GitHub. ## Instructions -### 1. Find Local Task File +Close the issue using the issue management script: -First check if `.claude/epics/*/$ARGUMENTS.md` exists (new naming). -If not found, search for task file with `github:.*issues/$ARGUMENTS` in frontmatter (old naming). -If not found: "โŒ No local task for issue #$ARGUMENTS" - -### 2. Update Local Status - -Get current datetime: `date -u +"%Y-%m-%dT%H:%M:%SZ"` - -Update task file frontmatter: -```yaml -status: closed -updated: {current_datetime} -``` - -### 3. Update Progress File - -If progress file exists at `.claude/epics/{epic}/updates/$ARGUMENTS/progress.md`: -- Set completion: 100% -- Add completion note with timestamp -- Update last_sync with current datetime - -### 4. Close on GitHub - -Add completion comment and close: ```bash -# Add final comment -echo "โœ… Task completed - -$ARGUMENTS - ---- -Closed at: {timestamp}" | gh issue comment $ARGUMENTS --body-file - +# Extract issue number and completion notes from arguments +issue_number=$(echo "$ARGUMENTS" | awk '{print $1}') +completion_notes=$(echo "$ARGUMENTS" | cut -d' ' -f2-) # Close the issue -gh issue close $ARGUMENTS +bash ccpm/scripts/pm/close-issue.sh close "$issue_number" "$completion_notes" ``` -### 5. Update Epic Task List on GitHub - -Check the task checkbox in the epic issue: - -```bash -# Get epic name from local task file path -epic_name={extract_from_path} - -# Get epic issue number from epic.md -epic_issue=$(grep 'github:' .claude/epics/$epic_name/epic.md | grep -oE '[0-9]+$') - -if [ ! -z "$epic_issue" ]; then - # Get current epic body - gh issue view $epic_issue --json body -q .body > /tmp/epic-body.md - - # Check off this task - sed -i "s/- \[ \] #$ARGUMENTS/- [x] #$ARGUMENTS/" /tmp/epic-body.md - - # Update epic issue - gh issue edit $epic_issue --body-file /tmp/epic-body.md - - echo "โœ“ Updated epic progress on GitHub" -fi -``` - -### 6. Update Epic Progress - -- Count total tasks in epic -- Count closed tasks -- Calculate new progress percentage -- Update epic.md frontmatter progress field - -### 7. Output - -``` -โœ… Closed issue #$ARGUMENTS - Local: Task marked complete - GitHub: Issue closed & epic updated - Epic progress: {new_progress}% ({closed}/{total} tasks complete) - -Next: Run /pm:next for next priority task -``` +The script will: +1. Find the local task file (supports both naming conventions) +2. Update local task status to closed with timestamp +3. Update progress file if it exists (set completion to 100%) +4. Close the GitHub issue with completion comment +5. Update epic task list on GitHub (check off the completed task) +6. Recalculate and update epic progress +7. Show completion summary with next steps ## Important Notes -Follow `/rules/frontmatter-operations.md` for updates. -Follow `/rules/github-operations.md` for GitHub commands. -Always sync local state before GitHub. \ No newline at end of file +- Automatically handles both old and new task file naming conventions +- Updates all related progress tracking files +- Maintains synchronization between local and GitHub state +- Provides clear feedback on what was updated \ No newline at end of file diff --git a/ccpm/commands/pm/issue-sync.md b/ccpm/commands/pm/issue-sync.md index fd8137cf8..b6ac73731 100644 --- a/ccpm/commands/pm/issue-sync.md +++ b/ccpm/commands/pm/issue-sync.md @@ -21,36 +21,12 @@ Push local updates as GitHub issue comments for transparent audit trail. Before proceeding, complete these validation steps. Do not bother the user with preflight checks progress ("I'm not going to ..."). Just do them and move on. -0. **Repository Protection Check:** - Follow `/rules/github-operations.md` - check remote origin: - ```bash - remote_url=$(git remote get-url origin 2>/dev/null || echo "") - if [[ "$remote_url" == *"automazeio/ccpm"* ]]; then - echo "โŒ ERROR: Cannot sync to CCPM template repository!" - echo "Update your remote: git remote set-url origin https://github.com/YOUR_USERNAME/YOUR_REPO.git" - exit 1 - fi - ``` - -1. **GitHub Authentication:** - - Run: `gh auth status` - - If not authenticated, tell user: "โŒ GitHub CLI not authenticated. Run: gh auth login" - -2. **Issue Validation:** - - Run: `gh issue view $ARGUMENTS --json state` - - If issue doesn't exist, tell user: "โŒ Issue #$ARGUMENTS not found" - - If issue is closed and completion < 100%, warn: "โš ๏ธ Issue is closed but work incomplete" - -3. **Local Updates Check:** - - Check if `.claude/epics/*/updates/$ARGUMENTS/` directory exists - - If not found, tell user: "โŒ No local updates found for issue #$ARGUMENTS. Run: /pm:issue-start $ARGUMENTS" - - Check if progress.md exists - - If not, tell user: "โŒ No progress tracking found. Initialize with: /pm:issue-start $ARGUMENTS" - -4. **Check Last Sync:** - - Read `last_sync` from progress.md frontmatter - - If synced recently (< 5 minutes), ask: "โš ๏ธ Recently synced. Force sync anyway? (yes/no)" - - Calculate what's new since last sync +Run preflight checks: +```bash +!bash ccpm/scripts/pm/issue-sync/check-repo-protection.sh +!bash ccpm/scripts/pm/issue-sync/preflight-validation.sh "$ARGUMENTS" +!bash ccpm/scripts/pm/issue-sync/check-sync-timing.sh "$ARGUMENTS" +``` 5. **Verify Changes:** - Check if there are actual updates to sync @@ -126,21 +102,13 @@ Create comprehensive update comment: ### 5. Post to GitHub Use GitHub CLI to add comment: ```bash -gh issue comment #$ARGUMENTS --body-file {temp_comment_file} +!bash ccpm/scripts/pm/issue-sync/post-comment.sh "$ARGUMENTS" "{temp_comment_file}" ``` ### 6. Update Local Task File -Get current datetime: `date -u +"%Y-%m-%dT%H:%M:%SZ"` - -Update the task file frontmatter with sync information: -```yaml ---- -name: [Task Title] -status: open -created: [preserve existing date] -updated: [Use REAL datetime from command above] -github: https://github.com/{org}/{repo}/issues/$ARGUMENTS ---- +Update frontmatter with sync information: +```bash +!bash ccpm/scripts/pm/issue-sync/update-frontmatter.sh "$ARGUMENTS" "{completion_percentage}" ``` ### 7. Handle Completion @@ -274,12 +242,10 @@ This task is ready for review and can be closed. ### 14. Epic Progress Calculation -When updating epic progress: -1. Count total tasks in epic directory -2. Count tasks with `status: closed` in frontmatter -3. Calculate: `progress = (closed_tasks / total_tasks) * 100` -4. Round to nearest integer -5. Update epic frontmatter only if percentage changed +When updating epic progress (replace {epic_name} with actual epic): +```bash +!bash ccpm/scripts/pm/issue-sync/calculate-epic-progress.sh "{epic_name}" +``` ### 15. Post-Sync Validation diff --git a/ccpm/lib/datetime.sh b/ccpm/lib/datetime.sh new file mode 100644 index 000000000..d2618dcec --- /dev/null +++ b/ccpm/lib/datetime.sh @@ -0,0 +1,298 @@ +#!/bin/bash + +# DateTime Utility Library +# Provides cross-platform functions for handling ISO timestamps and date operations + +# Get current timestamp in ISO 8601 format (UTC) +# Usage: get_current_iso_timestamp +# Returns: Current time in format "2023-12-25T10:30:45Z" +get_current_iso_timestamp() { + date -u +"%Y-%m-%dT%H:%M:%SZ" +} + +# Convert ISO timestamp to Unix timestamp (seconds since epoch) +# Usage: iso_to_timestamp "2023-12-25T10:30:45Z" +# Returns: Unix timestamp or "0" on error +iso_to_timestamp() { + local iso_date="$1" + + if [ -z "$iso_date" ]; then + echo "0" + return 1 + fi + + # Try GNU date format first (Linux) + local timestamp + timestamp=$(date -d "$iso_date" "+%s" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$timestamp" ]; then + echo "$timestamp" + return 0 + fi + + # Try macOS date format + timestamp=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$iso_date" "+%s" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$timestamp" ]; then + echo "$timestamp" + return 0 + fi + + # Fallback: try without timezone suffix + local clean_date + clean_date=$(echo "$iso_date" | sed 's/Z$//') + + # Try GNU date with cleaned input + timestamp=$(date -d "${clean_date}Z" "+%s" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$timestamp" ]; then + echo "$timestamp" + return 0 + fi + + # Try macOS date with cleaned input + timestamp=$(date -j -f "%Y-%m-%dT%H:%M:%S" "$clean_date" "+%s" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$timestamp" ]; then + echo "$timestamp" + return 0 + fi + + echo "0" + return 1 +} + +# Convert Unix timestamp to ISO format +# Usage: timestamp_to_iso "1703505045" +# Returns: ISO timestamp or empty on error +timestamp_to_iso() { + local timestamp="$1" + + if [ -z "$timestamp" ] || [ "$timestamp" = "0" ]; then + echo "" + return 1 + fi + + # Try GNU date format first + local iso_date + iso_date=$(date -u -d "@$timestamp" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$iso_date" ]; then + echo "$iso_date" + return 0 + fi + + # Try macOS date format + iso_date=$(date -u -r "$timestamp" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$iso_date" ]; then + echo "$iso_date" + return 0 + fi + + echo "" + return 1 +} + +# Compare two ISO timestamps and return which is newer +# Usage: compare_iso_timestamps "2023-01-01T10:00:00Z" "2023-01-02T10:00:00Z" +# Returns: "first" if first is newer, "second" if second is newer, "equal" if same +# Exit code: 0 if comparison successful, 1 on error +compare_iso_timestamps() { + local first_iso="$1" + local second_iso="$2" + + if [ -z "$first_iso" ] || [ -z "$second_iso" ]; then + echo "error" + return 1 + fi + + local first_ts + local second_ts + + first_ts=$(iso_to_timestamp "$first_iso") + second_ts=$(iso_to_timestamp "$second_iso") + + if [ "$first_ts" = "0" ] || [ "$second_ts" = "0" ]; then + echo "error" + return 1 + fi + + if [ "$first_ts" -gt "$second_ts" ]; then + echo "first" + elif [ "$first_ts" -lt "$second_ts" ]; then + echo "second" + else + echo "equal" + fi + + return 0 +} + +# Check if first timestamp is newer than second +# Usage: is_timestamp_newer "2023-01-02T10:00:00Z" "2023-01-01T10:00:00Z" +# Returns: 0 (true) if first is newer, 1 (false) otherwise +is_timestamp_newer() { + local first_iso="$1" + local second_iso="$2" + + local result + result=$(compare_iso_timestamps "$first_iso" "$second_iso") + + [ "$result" = "first" ] +} + +# Get relative time description (e.g., "3 days ago", "2 hours ago") +# Usage: get_relative_time "2023-12-20T10:00:00Z" +# Returns: Human-readable relative time or "unknown" on error +get_relative_time() { + local iso_date="$1" + + if [ -z "$iso_date" ]; then + echo "unknown" + return 1 + fi + + local target_ts + local current_ts + + target_ts=$(iso_to_timestamp "$iso_date") + current_ts=$(iso_to_timestamp "$(get_current_iso_timestamp)") + + if [ "$target_ts" = "0" ] || [ "$current_ts" = "0" ]; then + echo "unknown" + return 1 + fi + + local diff=$((current_ts - target_ts)) + + # Handle future dates + if [ $diff -lt 0 ]; then + diff=$((-diff)) + local suffix="from now" + else + local suffix="ago" + fi + + # Convert to appropriate units + if [ $diff -lt 60 ]; then + echo "${diff} seconds $suffix" + elif [ $diff -lt 3600 ]; then + local minutes=$((diff / 60)) + echo "${minutes} minute$([ $minutes -ne 1 ] && echo "s") $suffix" + elif [ $diff -lt 86400 ]; then + local hours=$((diff / 3600)) + echo "${hours} hour$([ $hours -ne 1 ] && echo "s") $suffix" + elif [ $diff -lt 2592000 ]; then + local days=$((diff / 86400)) + echo "${days} day$([ $days -ne 1 ] && echo "s") $suffix" + elif [ $diff -lt 31536000 ]; then + local months=$((diff / 2592000)) + echo "${months} month$([ $months -ne 1 ] && echo "s") $suffix" + else + local years=$((diff / 31536000)) + echo "${years} year$([ $years -ne 1 ] && echo "s") $suffix" + fi +} + +# Get age of file in days based on ISO timestamp in frontmatter +# Usage: get_file_age_days "task.md" "updated" +# Returns: Number of days since timestamp, or -1 on error +get_file_age_days() { + local file="$1" + local timestamp_field="${2:-updated}" + + if [ ! -f "$file" ]; then + echo "-1" + return 1 + fi + + # Source frontmatter library if not already loaded + if ! type get_frontmatter_field >/dev/null 2>&1; then + local script_dir="$(dirname "${BASH_SOURCE[0]}")" + source "$script_dir/frontmatter.sh" + fi + + local file_timestamp + file_timestamp=$(get_frontmatter_field "$file" "$timestamp_field") + + if [ -z "$file_timestamp" ]; then + echo "-1" + return 1 + fi + + local file_ts + local current_ts + + file_ts=$(iso_to_timestamp "$file_timestamp") + current_ts=$(iso_to_timestamp "$(get_current_iso_timestamp)") + + if [ "$file_ts" = "0" ] || [ "$current_ts" = "0" ]; then + echo "-1" + return 1 + fi + + local diff_days=$(((current_ts - file_ts) / 86400)) + echo "$diff_days" +} + +# Check if a file is older than specified number of days +# Usage: is_file_older_than_days "task.md" 30 "updated" +# Returns: 0 (true) if older, 1 (false) if newer or on error +is_file_older_than_days() { + local file="$1" + local max_days="$2" + local timestamp_field="${3:-updated}" + + local age_days + age_days=$(get_file_age_days "$file" "$timestamp_field") + + if [ "$age_days" -eq -1 ]; then + return 1 + fi + + [ "$age_days" -gt "$max_days" ] +} + +# Format timestamp for human display +# Usage: format_timestamp_display "2023-12-25T10:30:45Z" +# Returns: "Dec 25, 2023 10:30 UTC" or original string on error +format_timestamp_display() { + local iso_date="$1" + + if [ -z "$iso_date" ]; then + echo "" + return 1 + fi + + local timestamp + timestamp=$(iso_to_timestamp "$iso_date") + + if [ "$timestamp" = "0" ]; then + echo "$iso_date" # Return original if can't parse + return 1 + fi + + # Try to format in a readable way (cross-platform) + local formatted + + # GNU date + formatted=$(date -u -d "@$timestamp" "+%b %d, %Y %H:%M UTC" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$formatted" ]; then + echo "$formatted" + return 0 + fi + + # macOS date + formatted=$(date -u -r "$timestamp" "+%b %d, %Y %H:%M UTC" 2>/dev/null) + + if [ $? -eq 0 ] && [ -n "$formatted" ]; then + echo "$formatted" + return 0 + fi + + # Fallback to original + echo "$iso_date" + return 1 +} \ No newline at end of file diff --git a/ccpm/lib/dependencies.sh b/ccpm/lib/dependencies.sh new file mode 100644 index 000000000..f17a8f349 --- /dev/null +++ b/ccpm/lib/dependencies.sh @@ -0,0 +1,285 @@ +#!/bin/bash + +# Dependency Parsing Utility Library +# Provides functions for parsing and validating task dependencies from frontmatter + +# Source frontmatter library for field extraction +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +source "$SCRIPT_DIR/frontmatter.sh" + +# Get task dependencies as space-separated list +# Usage: get_task_dependencies "task_file.md" +# Returns: Space-separated dependency list (e.g., "001 002 003") or empty string +get_task_dependencies() { + local file="$1" + + if [ ! -f "$file" ]; then + echo "" + return 1 + fi + + # Extract dependencies line + local deps_line + deps_line=$(grep "^depends_on:" "$file" 2>/dev/null | head -1) + + if [ -z "$deps_line" ]; then + echo "" + return 0 + fi + + # Extract and clean dependencies + local deps + deps=$(echo "$deps_line" | sed 's/^depends_on:[[:space:]]*//') + + # Remove brackets + deps=$(echo "$deps" | sed 's/^\[//' | sed 's/\]$//') + + # Convert commas to spaces + deps=$(echo "$deps" | sed 's/,/ /g') + + # Trim whitespace + deps=$(echo "$deps" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + + # Normalize multiple spaces to single spaces + deps=$(echo "$deps" | tr -s ' ') + + # Handle empty/malformed cases + if [ -z "$deps" ] || [ "$deps" = "depends_on:" ]; then + echo "" + else + echo "$deps" + fi + + return 0 +} + +# Get task dependencies as bash array +# Usage: +# deps_array=($(get_task_dependencies_array "task_file.md")) +# Returns: Dependencies as separate elements suitable for array assignment +get_task_dependencies_array() { + local file="$1" + local deps_string + + deps_string=$(get_task_dependencies "$file") + + if [ -n "$deps_string" ]; then + echo "$deps_string" + fi +} + +# Check if a task has any dependencies +# Usage: has_dependencies "task_file.md" +# Returns: 0 if has dependencies, 1 if no dependencies +has_dependencies() { + local file="$1" + local deps + + deps=$(get_task_dependencies "$file") + [ -n "$deps" ] +} + +# Check if all dependencies for a task are satisfied (closed) +# Usage: are_dependencies_satisfied "task_file.md" "epic_dir" +# Returns: 0 if all dependencies satisfied, 1 if any are still open/missing +are_dependencies_satisfied() { + local task_file="$1" + local epic_dir="$2" + + if [ ! -f "$task_file" ] || [ ! -d "$epic_dir" ]; then + return 1 + fi + + local deps + deps=$(get_task_dependencies "$task_file") + + # No dependencies means satisfied + if [ -z "$deps" ]; then + return 0 + fi + + # Check each dependency + for dep in $deps; do + local dep_file="$epic_dir/$dep.md" + + # Dependency file must exist + if [ ! -f "$dep_file" ]; then + return 1 + fi + + # Dependency must be closed + local dep_status + dep_status=$(get_frontmatter_field "$dep_file" "status" "open") + if [ "$dep_status" != "closed" ]; then + return 1 + fi + done + + return 0 +} + +# Get list of unsatisfied dependencies +# Usage: get_unsatisfied_dependencies "task_file.md" "epic_dir" +# Returns: Space-separated list of dependency IDs that are not satisfied +get_unsatisfied_dependencies() { + local task_file="$1" + local epic_dir="$2" + local unsatisfied="" + + if [ ! -f "$task_file" ] || [ ! -d "$epic_dir" ]; then + echo "" + return 1 + fi + + local deps + deps=$(get_task_dependencies "$task_file") + + if [ -z "$deps" ]; then + echo "" + return 0 + fi + + # Check each dependency + for dep in $deps; do + local dep_file="$epic_dir/$dep.md" + + # Check if dependency file exists and is closed + if [ ! -f "$dep_file" ]; then + unsatisfied="$unsatisfied $dep" + else + local dep_status + dep_status=$(get_frontmatter_field "$dep_file" "status" "open") + if [ "$dep_status" != "closed" ]; then + unsatisfied="$unsatisfied $dep" + fi + fi + done + + # Trim leading space + echo "$unsatisfied" | sed 's/^[[:space:]]*//' +} + +# Validate dependency references (check for circular dependencies, missing refs) +# Usage: validate_task_dependencies "epic_dir" +# Returns: 0 if all dependencies valid, 1 if issues found +# Outputs: Warning messages for any issues found +validate_task_dependencies() { + local epic_dir="$1" + local issues_found=0 + + if [ ! -d "$epic_dir" ]; then + echo "Error: Epic directory $epic_dir does not exist" >&2 + return 1 + fi + + # Check each task file + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + local task_id + task_id=$(basename "$task_file" .md) + + local deps + deps=$(get_task_dependencies "$task_file") + + if [ -n "$deps" ]; then + # Check for self-dependency + for dep in $deps; do + if [ "$dep" = "$task_id" ]; then + echo "Warning: Task $task_id depends on itself" >&2 + issues_found=1 + fi + + # Check if dependency file exists + if [ ! -f "$epic_dir/$dep.md" ]; then + echo "Warning: Task $task_id depends on missing task $dep" >&2 + issues_found=1 + fi + done + fi + done + + # TODO: Check for circular dependencies (would require graph traversal) + # For now, just check for direct circular references + + return $issues_found +} + +# Update task dependencies +# Usage: update_task_dependencies "task_file.md" "001 002 003" +# Returns: 0 on success, 1 on failure +update_task_dependencies() { + local file="$1" + local deps_string="$2" + + if [ ! -f "$file" ]; then + echo "Error: File $file does not exist" >&2 + return 1 + fi + + # Format as YAML array + if [ -n "$deps_string" ]; then + local formatted_deps="[$deps_string]" + # Replace spaces with commas for YAML array format + formatted_deps=$(echo "$formatted_deps" | sed 's/ /, /g') + update_frontmatter_field "$file" "depends_on" "$formatted_deps" + else + update_frontmatter_field "$file" "depends_on" "[]" + fi + + return 0 +} + +# Add a dependency to a task +# Usage: add_task_dependency "task_file.md" "dependency_id" +# Returns: 0 on success, 1 on failure +add_task_dependency() { + local file="$1" + local new_dep="$2" + + if [ ! -f "$file" ]; then + echo "Error: File $file does not exist" >&2 + return 1 + fi + + local current_deps + current_deps=$(get_task_dependencies "$file") + + # Check if dependency already exists + if echo "$current_deps" | grep -q "$new_dep"; then + return 0 # Already exists, nothing to do + fi + + # Add new dependency + if [ -n "$current_deps" ]; then + update_task_dependencies "$file" "$current_deps $new_dep" + else + update_task_dependencies "$file" "$new_dep" + fi + + return 0 +} + +# Remove a dependency from a task +# Usage: remove_task_dependency "task_file.md" "dependency_id" +# Returns: 0 on success, 1 on failure +remove_task_dependency() { + local file="$1" + local dep_to_remove="$2" + + if [ ! -f "$file" ]; then + echo "Error: File $file does not exist" >&2 + return 1 + fi + + local current_deps + current_deps=$(get_task_dependencies "$file") + + # Remove the dependency from the list + local new_deps + new_deps=$(echo "$current_deps" | sed "s/\\b${dep_to_remove}\\b//g" | tr -s ' ' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + + update_task_dependencies "$file" "$new_deps" + + return 0 +} \ No newline at end of file diff --git a/ccpm/lib/discovery.sh b/ccpm/lib/discovery.sh new file mode 100644 index 000000000..d3f00d01f --- /dev/null +++ b/ccpm/lib/discovery.sh @@ -0,0 +1,434 @@ +#!/bin/bash + +# Discovery and Navigation Utility Library +# Provides functions for finding and iterating through epics, tasks, and project structure + +# Source required libraries +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +source "$SCRIPT_DIR/frontmatter.sh" + +# Get list of all epic directories +# Usage: get_all_epic_dirs +# Returns: Space-separated list of epic directory paths +get_all_epic_dirs() { + find .claude/epics -maxdepth 1 -type d -name "*" | grep -v "^\.claude/epics$" | grep -v "\.archived" | sort +} + +# Get list of all epic names +# Usage: get_all_epic_names +# Returns: Space-separated list of epic names +get_all_epic_names() { + local epic_names=() + + for epic_dir in $(get_all_epic_dirs); do + local epic_name + epic_name=$(basename "$epic_dir") + echo "$epic_name" + done +} + +# Check if epic exists +# Usage: epic_exists "epic_name" +# Returns: 0 if exists, 1 if not +epic_exists() { + local epic_name="$1" + + if [ -z "$epic_name" ]; then + return 1 + fi + + [ -f ".claude/epics/$epic_name/epic.md" ] +} + +# Get all task files for an epic +# Usage: get_epic_task_files "epic_name" +# Returns: List of task file paths (one per line) +get_epic_task_files() { + local epic_name="$1" + + if [ -z "$epic_name" ] || ! epic_exists "$epic_name"; then + return 1 + fi + + local epic_dir=".claude/epics/$epic_name" + + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] && echo "$task_file" + done | sort +} + +# Count total tasks in an epic +# Usage: get_epic_task_count "epic_name" +# Returns: Number of tasks (0 if epic not found) +get_epic_task_count() { + local epic_name="$1" + + if [ -z "$epic_name" ]; then + echo "0" + return 1 + fi + + get_epic_task_files "$epic_name" | wc -l | tr -d ' ' +} + +# Count tasks in an epic by status +# Usage: get_epic_task_count_by_status "epic_name" "status" +# Returns: Number of tasks with specified status +get_epic_task_count_by_status() { + local epic_name="$1" + local status="$2" + + if [ -z "$epic_name" ] || [ -z "$status" ]; then + echo "0" + return 1 + fi + + local count=0 + + while IFS= read -r task_file; do + [ -f "$task_file" ] || continue + + local task_status + task_status=$(get_frontmatter_field "$task_file" "status" "open") + + if [ "$task_status" = "$status" ]; then + count=$((count + 1)) + fi + done < <(get_epic_task_files "$epic_name") + + echo "$count" +} + +# Calculate epic progress percentage +# Usage: get_epic_progress "epic_name" +# Returns: Progress percentage (0-100) or 0 if no tasks +get_epic_progress() { + local epic_name="$1" + + if [ -z "$epic_name" ]; then + echo "0" + return 1 + fi + + local total_tasks + local closed_tasks + + total_tasks=$(get_epic_task_count "$epic_name") + closed_tasks=$(get_epic_task_count_by_status "$epic_name" "closed") + + if [ "$total_tasks" -eq 0 ]; then + echo "0" + return 0 + fi + + # Calculate percentage (bash integer arithmetic) + local progress=$((closed_tasks * 100 / total_tasks)) + echo "$progress" +} + +# Find epic that contains a specific issue number +# Usage: find_epic_containing_issue "123" +# Returns: Epic name or empty if not found +find_epic_containing_issue() { + local issue_num="$1" + + if [ -z "$issue_num" ]; then + return 1 + fi + + # First check for new naming convention (issue_number.md) + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + if [ -f "$epic_dir/$issue_num.md" ]; then + basename "$epic_dir" + return 0 + fi + done + + # Check updates directory structure + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + if [ -d "$epic_dir/updates/$issue_num/" ]; then + basename "$epic_dir" + return 0 + fi + done + + # Fallback to searching by GitHub URL in frontmatter + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + if grep -q "github:.*issues/$issue_num" "$task_file"; then + basename "$epic_dir" + return 0 + fi + done + done + + return 1 +} + +# Find next available task number in an epic +# Usage: get_next_task_number "epic_name" +# Returns: Next available number (e.g., "004") or "001" if no tasks exist +get_next_task_number() { + local epic_name="$1" + + if [ -z "$epic_name" ] || ! epic_exists "$epic_name"; then + echo "001" + return 1 + fi + + local epic_dir=".claude/epics/$epic_name" + local max_num=0 + + # Find highest existing task number + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + local filename + filename=$(basename "$task_file" .md) + + # Extract numeric part (handle both formats: "001" and "1234") + if [[ "$filename" =~ ^[0-9]+$ ]]; then + local num=$((10#$filename)) # Force base 10 to handle leading zeros + if [ "$num" -gt "$max_num" ]; then + max_num="$num" + fi + fi + done + + # Return next number with zero padding + local next_num=$((max_num + 1)) + printf "%03d" "$next_num" +} + +# Iterate through all task files with a callback function +# Usage: iterate_all_task_files "callback_function" +# Callback receives: task_file_path epic_name task_basename +iterate_all_task_files() { + local callback="$1" + + if [ -z "$callback" ]; then + echo "Error: Callback function required" >&2 + return 1 + fi + + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + local epic_name + epic_name=$(basename "$epic_dir") + + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + local task_basename + task_basename=$(basename "$task_file" .md) + + # Call the callback function with parameters + "$callback" "$task_file" "$epic_name" "$task_basename" + done + done +} + +# Find all tasks with specific status across all epics +# Usage: find_tasks_by_status "open" +# Returns: List of task file paths (one per line) +find_tasks_by_status() { + local target_status="$1" + + if [ -z "$target_status" ]; then + return 1 + fi + + local matching_tasks=() + + iterate_all_task_files() { + local task_file="$1" + local epic_name="$2" + local task_basename="$3" + + local task_status + task_status=$(get_frontmatter_field "$task_file" "status" "open") + + if [ "$task_status" = "$target_status" ]; then + echo "$task_file" + fi + } + + iterate_all_task_files "iterate_all_task_files" +} + +# Find all tasks that are ready to work on (open with satisfied dependencies) +# Usage: find_available_tasks +# Returns: List of task file paths (one per line) +find_available_tasks() { + # Source dependencies library if not already loaded + if ! type get_task_dependencies >/dev/null 2>&1; then + source "$SCRIPT_DIR/dependencies.sh" + fi + + iterate_all_task_files() { + local task_file="$1" + local epic_name="$2" + local task_basename="$3" + + # Check if task is open + local task_status + task_status=$(get_frontmatter_field "$task_file" "status" "open") + + if [ "$task_status" != "open" ]; then + return 0 + fi + + # Check if dependencies are satisfied + local epic_dir=".claude/epics/$epic_name" + + if are_dependencies_satisfied "$task_file" "$epic_dir"; then + echo "$task_file" + fi + } + + iterate_all_task_files "iterate_all_task_files" +} + +# Find all tasks that are blocked by dependencies +# Usage: find_blocked_tasks +# Returns: List of task file paths (one per line) +find_blocked_tasks() { + # Source dependencies library if not already loaded + if ! type get_task_dependencies >/dev/null 2>&1; then + source "$SCRIPT_DIR/dependencies.sh" + fi + + iterate_all_task_files() { + local task_file="$1" + local epic_name="$2" + local task_basename="$3" + + # Check if task is open + local task_status + task_status=$(get_frontmatter_field "$task_file" "status" "open") + + if [ "$task_status" != "open" ]; then + return 0 + fi + + # Check if task has dependencies and they're not satisfied + if has_dependencies "$task_file"; then + local epic_dir=".claude/epics/$epic_name" + + if ! are_dependencies_satisfied "$task_file" "$epic_dir"; then + echo "$task_file" + fi + fi + } + + iterate_all_task_files "iterate_all_task_files" +} + +# Get all tasks in progress (status: in-progress) +# Usage: find_in_progress_tasks +# Returns: List of task file paths (one per line) +find_in_progress_tasks() { + find_tasks_by_status "in-progress" +} + +# Find all parallel tasks in an epic +# Usage: find_parallel_tasks "epic_name" +# Returns: List of task file paths (one per line) +find_parallel_tasks() { + local epic_name="$1" + + if [ -z "$epic_name" ]; then + return 1 + fi + + while IFS= read -r task_file; do + [ -f "$task_file" ] || continue + + local parallel + parallel=$(get_frontmatter_field "$task_file" "parallel" "false") + + if [ "$parallel" = "true" ]; then + echo "$task_file" + fi + done < <(get_epic_task_files "$epic_name") +} + +# Get epic summary information +# Usage: get_epic_summary "epic_name" +# Returns: Multi-line summary with epic stats +get_epic_summary() { + local epic_name="$1" + + if [ -z "$epic_name" ] || ! epic_exists "$epic_name"; then + echo "Epic not found: $epic_name" + return 1 + fi + + local total_tasks + local open_tasks + local closed_tasks + local in_progress_tasks + local progress + + total_tasks=$(get_epic_task_count "$epic_name") + open_tasks=$(get_epic_task_count_by_status "$epic_name" "open") + closed_tasks=$(get_epic_task_count_by_status "$epic_name" "closed") + in_progress_tasks=$(get_epic_task_count_by_status "$epic_name" "in-progress") + progress=$(get_epic_progress "$epic_name") + + echo "Epic: $epic_name" + echo "Progress: ${progress}% (${closed_tasks}/${total_tasks} tasks complete)" + echo "Open: $open_tasks" + echo "In Progress: $in_progress_tasks" + echo "Closed: $closed_tasks" + echo "Total: $total_tasks" +} + +# Find stale files (older than specified days with no updates) +# Usage: find_stale_files "30" ["status_filter"] +# Returns: List of file paths (one per line) +find_stale_files() { + local max_age_days="$1" + local status_filter="${2:-}" + + if [ -z "$max_age_days" ]; then + max_age_days=30 + fi + + # Source datetime library if not already loaded + if ! type get_file_age_days >/dev/null 2>&1; then + source "$SCRIPT_DIR/datetime.sh" + fi + + iterate_all_task_files() { + local task_file="$1" + local epic_name="$2" + local task_basename="$3" + + # Apply status filter if specified + if [ -n "$status_filter" ]; then + local task_status + task_status=$(get_frontmatter_field "$task_file" "status" "open") + + if [ "$task_status" != "$status_filter" ]; then + return 0 + fi + fi + + # Check if file is stale + if is_file_older_than_days "$task_file" "$max_age_days" "updated"; then + echo "$task_file" + fi + } + + iterate_all_task_files "iterate_all_task_files" +} \ No newline at end of file diff --git a/ccpm/lib/error.sh b/ccpm/lib/error.sh new file mode 100644 index 000000000..e5151392b --- /dev/null +++ b/ccpm/lib/error.sh @@ -0,0 +1,348 @@ +#!/bin/bash + +# Error Handling and Validation Utility Library +# Provides consistent error handling, validation, and messaging functions + +# Set strict mode for bash scripts +# Usage: set_strict_mode +# Enables: exit on error, undefined vars, pipe failures +set_strict_mode() { + set -euo pipefail + + # Set up error trap to show line number on failures + trap 'echo "โŒ Error on line $LINENO in ${BASH_SOURCE[0]}. Exit code: $?" >&2' ERR +} + +# Exit with error message +# Usage: error_exit "Error message" [exit_code] +# Default exit code: 1 +error_exit() { + local message="$1" + local exit_code="${2:-1}" + + echo "โŒ Error: $message" >&2 + exit "$exit_code" +} + +# Print warning message (non-fatal) +# Usage: warning "Warning message" +warning() { + local message="$1" + echo "โš ๏ธ Warning: $message" >&2 +} + +# Print info message +# Usage: info "Info message" +info() { + local message="$1" + echo "โ„น๏ธ Info: $message" >&2 +} + +# Print success message +# Usage: success "Success message" +success() { + local message="$1" + echo "โœ… $message" >&2 +} + +# Check if required commands are available +# Usage: require_commands "command1" "command2" ... +# Exits if any command is missing +require_commands() { + local commands=("$@") + local missing_commands=() + + for cmd in "${commands[@]}"; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing_commands+=("$cmd") + fi + done + + if [ ${#missing_commands[@]} -gt 0 ]; then + error_exit "Missing required commands: ${missing_commands[*]}" + fi +} + +# Validate that an epic exists and is properly formed +# Usage: validate_epic_name "epic_name" +# Returns: 0 on success, exits on failure +validate_epic_name() { + local epic_name="$1" + + if [ -z "$epic_name" ]; then + error_exit "Epic name is required" + fi + + local epic_file=".claude/epics/$epic_name/epic.md" + + if [ ! -f "$epic_file" ]; then + error_exit "Epic not found: $epic_name. Create it first with: /pm:prd-parse $epic_name" + fi + + # Check if epic has required frontmatter + if ! grep -q "^name:" "$epic_file"; then + error_exit "Epic file missing required frontmatter: $epic_file" + fi + + return 0 +} + +# Validate that a task file exists and is properly formed +# Usage: validate_task_file "path/to/task.md" +# Returns: 0 on success, exits on failure +validate_task_file() { + local task_file="$1" + + if [ -z "$task_file" ]; then + error_exit "Task file path is required" + fi + + if [ ! -f "$task_file" ]; then + error_exit "Task file not found: $task_file" + fi + + # Check if task has required frontmatter + local required_fields=("name" "status") + for field in "${required_fields[@]}"; do + if ! grep -q "^$field:" "$task_file"; then + error_exit "Task file missing required field '$field': $task_file" + fi + done + + return 0 +} + +# Validate GitHub CLI authentication +# Usage: validate_github_auth +# Returns: 0 if authenticated, exits if not +validate_github_auth() { + # Source GitHub library if not already loaded + if ! type check_github_auth >/dev/null 2>&1; then + local script_dir="$(dirname "${BASH_SOURCE[0]}")" + source "$script_dir/github.sh" + fi + + if ! check_github_auth; then + error_exit "GitHub CLI not authenticated. Run: gh auth login" + fi + + return 0 +} + +# Validate that we're in a Git repository +# Usage: validate_git_repo +# Returns: 0 if in git repo, exits if not +validate_git_repo() { + if ! git rev-parse --git-dir >/dev/null 2>&1; then + error_exit "Not in a Git repository" + fi + + return 0 +} + +# Validate directory structure exists +# Usage: validate_directory_structure +# Returns: 0 on success, exits on failure +validate_directory_structure() { + local required_dirs=( + ".claude" + ".claude/epics" + ".claude/context" + ".claude/commands" + ".claude/scripts" + ) + + for dir in "${required_dirs[@]}"; do + if [ ! -d "$dir" ]; then + error_exit "Required directory missing: $dir. Run /pm:init to set up the system." + fi + done + + return 0 +} + +# Validate that issue number is valid +# Usage: validate_issue_number "123" +# Returns: 0 on success, exits on failure +validate_issue_number() { + local issue_num="$1" + + if [ -z "$issue_num" ]; then + error_exit "Issue number is required" + fi + + # Check if it's a valid number + if ! [[ "$issue_num" =~ ^[0-9]+$ ]]; then + error_exit "Invalid issue number: $issue_num (must be a positive integer)" + fi + + return 0 +} + +# Find and validate task file for an issue number +# Usage: find_task_file_for_issue "123" +# Returns: Path to task file, or exits if not found +find_task_file_for_issue() { + local issue_num="$1" + + validate_issue_number "$issue_num" + + # First check for new naming convention (issue_number.md) + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + local task_file="$epic_dir/$issue_num.md" + if [ -f "$task_file" ]; then + echo "$task_file" + return 0 + fi + done + + # Fallback to searching by GitHub URL in frontmatter + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + if grep -q "github:.*issues/$issue_num" "$task_file"; then + echo "$task_file" + return 0 + fi + done + done + + error_exit "No local task found for issue #$issue_num" +} + +# Validate file is writable +# Usage: validate_file_writable "path/to/file" +# Returns: 0 if writable, exits if not +validate_file_writable() { + local file="$1" + + if [ -z "$file" ]; then + error_exit "File path is required" + fi + + if [ -f "$file" ] && [ ! -w "$file" ]; then + error_exit "File is not writable: $file" + fi + + # Check if parent directory is writable for new files + local parent_dir + parent_dir="$(dirname "$file")" + + if [ ! -d "$parent_dir" ]; then + error_exit "Parent directory does not exist: $parent_dir" + fi + + if [ ! -w "$parent_dir" ]; then + error_exit "Parent directory is not writable: $parent_dir" + fi + + return 0 +} + +# Validate that a directory exists and is writable +# Usage: validate_directory_writable "path/to/dir" +# Returns: 0 if valid, exits if not +validate_directory_writable() { + local dir="$1" + + if [ -z "$dir" ]; then + error_exit "Directory path is required" + fi + + if [ ! -d "$dir" ]; then + error_exit "Directory does not exist: $dir" + fi + + if [ ! -w "$dir" ]; then + error_exit "Directory is not writable: $dir" + fi + + return 0 +} + +# Check if running in dry-run mode +# Usage: is_dry_run "$@" +# Returns: 0 if --dry-run flag found, 1 if not +is_dry_run() { + local args=("$@") + + for arg in "${args[@]}"; do + if [ "$arg" = "--dry-run" ] || [ "$arg" = "-n" ]; then + return 0 + fi + done + + return 1 +} + +# Prompt for user confirmation +# Usage: confirm "Proceed with action?" ["default_response"] +# Returns: 0 for yes/y, 1 for no/n +# Default response: "n" if not specified +confirm() { + local message="$1" + local default="${2:-n}" + + echo -n "$message (y/n) [default: $default]: " >&2 + read -r response + + # Use default if no response + if [ -z "$response" ]; then + response="$default" + fi + + case "$response" in + [Yy]|[Yy][Ee][Ss]) + return 0 + ;; + [Nn]|[Nn][Oo]) + return 1 + ;; + *) + warning "Invalid response. Assuming 'no'." + return 1 + ;; + esac +} + +# Validate JSON structure +# Usage: validate_json "json_string" +# Returns: 0 if valid JSON, 1 if invalid +validate_json() { + local json_string="$1" + + if [ -z "$json_string" ]; then + return 1 + fi + + echo "$json_string" | jq empty >/dev/null 2>&1 +} + +# Safe cleanup function for temporary files +# Usage: cleanup_temp_files "pattern1" "pattern2" ... +cleanup_temp_files() { + local patterns=("$@") + + for pattern in "${patterns[@]}"; do + # Use find for safety instead of rm with globs + find /tmp -maxdepth 1 -name "$pattern" -type f -mmin +60 -delete 2>/dev/null || true + done +} + +# Set up cleanup trap for script +# Usage: set_cleanup_trap "cleanup_function" +set_cleanup_trap() { + local cleanup_function="$1" + + if [ -z "$cleanup_function" ]; then + error_exit "Cleanup function name required" + fi + + # Set trap for various exit conditions + trap "$cleanup_function" EXIT INT TERM +} \ No newline at end of file diff --git a/ccpm/lib/frontmatter.sh b/ccpm/lib/frontmatter.sh new file mode 100644 index 000000000..354b7f3a1 --- /dev/null +++ b/ccpm/lib/frontmatter.sh @@ -0,0 +1,194 @@ +#!/bin/bash + +# Frontmatter Utility Library +# Provides functions for parsing and updating YAML frontmatter in markdown files + +# Get a field value from frontmatter +# Usage: get_frontmatter_field "file.md" "field_name" ["default_value"] +# Returns: field value or default_value (empty string if no default provided) +get_frontmatter_field() { + local file="$1" + local field="$2" + local default="${3:-}" + + if [ ! -f "$file" ]; then + echo "${default}" + return 1 + fi + + # Extract field value, handling various formats + local value + value=$(grep "^${field}:" "$file" 2>/dev/null | head -1 | sed "s/^${field}:[[:space:]]*//" | sed 's/[[:space:]]*$//') + + if [ -n "$value" ]; then + echo "$value" + else + echo "${default}" + [ -z "$default" ] && return 1 + fi +} + +# Update a field in frontmatter (or add if it doesn't exist) +# Usage: update_frontmatter_field "file.md" "field_name" "new_value" +# Returns: 0 on success, 1 on failure +update_frontmatter_field() { + local file="$1" + local field="$2" + local value="$3" + + if [ ! -f "$file" ]; then + echo "Error: File $file does not exist" >&2 + return 1 + fi + + # Check if field exists + if grep -q "^${field}:" "$file"; then + # Update existing field (cross-platform sed) + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s/^${field}:.*/${field}: ${value}/" "$file" + else + sed -i "s/^${field}:.*/${field}: ${value}/" "$file" + fi + else + # Field doesn't exist - add it after the first --- line + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "1,/^---$/s/^---$/${field}: ${value}\n---/" "$file" + else + sed -i "1,/^---$/s/^---$/${field}: ${value}\n---/" "$file" + fi + fi + + return 0 +} + +# Strip frontmatter from a file and write content to another file +# Usage: strip_frontmatter_to_file "input.md" "output.md" +# Returns: 0 on success, 1 on failure +strip_frontmatter_to_file() { + local input_file="$1" + local output_file="$2" + + if [ ! -f "$input_file" ]; then + echo "Error: Input file $input_file does not exist" >&2 + return 1 + fi + + # Remove frontmatter (everything between first two --- lines) + sed '1,/^---$/d; 1,/^---$/d' "$input_file" > "$output_file" + return 0 +} + +# Check if file has valid frontmatter with required fields +# Usage: validate_frontmatter "file.md" "field1" "field2" ... +# Returns: 0 if all fields exist, 1 if any missing +validate_frontmatter() { + local file="$1" + shift + local required_fields=("$@") + + if [ ! -f "$file" ]; then + echo "Error: File $file does not exist" >&2 + return 1 + fi + + # Check if file has frontmatter at all + if ! grep -q "^---" "$file"; then + echo "Error: File $file has no frontmatter" >&2 + return 1 + fi + + # Check each required field + for field in "${required_fields[@]}"; do + if ! grep -q "^${field}:" "$file"; then + echo "Error: Missing required field: $field" >&2 + return 1 + fi + done + + return 0 +} + +# Get all frontmatter fields as key=value pairs +# Usage: get_all_frontmatter "file.md" +# Output: Each line is "field=value" +get_all_frontmatter() { + local file="$1" + + if [ ! -f "$file" ]; then + return 1 + fi + + # Extract frontmatter section and convert to key=value pairs + awk ' + BEGIN { in_frontmatter=0 } + /^---$/ { + if (in_frontmatter) exit + in_frontmatter=1; next + } + in_frontmatter && /^[^-]/ && /:/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0) + field = $1 + gsub(/:/, "", field) + value = substr($0, index($0, ":") + 1) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print field "=" value + } + ' "$file" +} + +# Add frontmatter to a file that doesn't have it +# Usage: add_frontmatter "file.md" "field1:value1" "field2:value2" ... +# Returns: 0 on success, 1 on failure +add_frontmatter() { + local file="$1" + shift + local fields=("$@") + + if [ ! -f "$file" ]; then + echo "Error: File $file does not exist" >&2 + return 1 + fi + + # Check if frontmatter already exists + if grep -q "^---" "$file"; then + echo "Error: File $file already has frontmatter" >&2 + return 1 + fi + + # Create temporary file with frontmatter + local temp_file="/tmp/frontmatter_$$" + echo "---" > "$temp_file" + for field_value in "${fields[@]}"; do + echo "$field_value" >> "$temp_file" + done + echo "---" >> "$temp_file" + echo "" >> "$temp_file" + cat "$file" >> "$temp_file" + + # Replace original file + mv "$temp_file" "$file" + return 0 +} + +# Update multiple frontmatter fields at once +# Usage: update_frontmatter_bulk "file.md" "field1:value1" "field2:value2" ... +# Returns: 0 on success, 1 on failure +update_frontmatter_bulk() { + local file="$1" + shift + local fields=("$@") + + if [ ! -f "$file" ]; then + echo "Error: File $file does not exist" >&2 + return 1 + fi + + # Update each field + for field_value in "${fields[@]}"; do + local field="${field_value%:*}" + local value="${field_value#*:}" + update_frontmatter_field "$file" "$field" "$value" + done + + return 0 +} \ No newline at end of file diff --git a/ccpm/lib/github.sh b/ccpm/lib/github.sh new file mode 100644 index 000000000..dbe81b5e4 --- /dev/null +++ b/ccpm/lib/github.sh @@ -0,0 +1,393 @@ +#!/bin/bash + +# GitHub CLI Utility Library +# Provides common functions for GitHub operations using gh CLI + +# Check if GitHub CLI is authenticated +# Usage: check_github_auth +# Returns: 0 if authenticated, 1 if not +check_github_auth() { + gh auth status >/dev/null 2>&1 +} + +# Validate GitHub authentication and exit on failure +# Usage: require_github_auth +# Returns: 0 if authenticated, exits with error if not +require_github_auth() { + if ! check_github_auth; then + echo "โŒ GitHub CLI not authenticated. Run: gh auth login" >&2 + exit 1 + fi +} + +# Get current repository name in owner/repo format +# Usage: get_current_repo +# Returns: Repository name (e.g., "owner/repo") or empty if not in a git repo +get_current_repo() { + # Try to get from gh CLI first + local repo + repo=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) + + if [ -n "$repo" ]; then + echo "$repo" + return 0 + fi + + # Fallback to parsing git remote + local remote_url + remote_url=$(git remote get-url origin 2>/dev/null) + + if [ -n "$remote_url" ]; then + # Parse different URL formats + local parsed_repo="$remote_url" + + # Handle https://github.com/owner/repo.git + parsed_repo=$(echo "$parsed_repo" | sed -E 's#^https://github\.com/##') + + # Handle git@github.com:owner/repo.git + parsed_repo=$(echo "$parsed_repo" | sed -E 's#^git@github\.com:##') + + # Handle ssh:// variants + parsed_repo=$(echo "$parsed_repo" | sed -E 's#^ssh://git@github\.com/##') + parsed_repo=$(echo "$parsed_repo" | sed -E 's#^ssh://github\.com/##') + + # Remove .git suffix + parsed_repo=$(echo "$parsed_repo" | sed 's#\.git$##') + + echo "$parsed_repo" + return 0 + fi + + echo "" + return 1 +} + +# Create a GitHub issue +# Usage: create_github_issue "title" "body" ["label1,label2"] ["assignee"] +# Returns: Issue number on success, empty on failure +create_github_issue() { + local title="$1" + local body="$2" + local labels="$3" + local assignee="$4" + local repo="${5:-}" + + if [ -z "$title" ]; then + echo "Error: Issue title required" >&2 + return 1 + fi + + # Build gh issue create command + local gh_cmd="gh issue create --title \"$title\"" + + if [ -n "$body" ]; then + # Create temp file for body + local body_file="/tmp/gh_issue_body_$$" + echo "$body" > "$body_file" + gh_cmd="$gh_cmd --body-file \"$body_file\"" + fi + + if [ -n "$labels" ]; then + gh_cmd="$gh_cmd --label \"$labels\"" + fi + + if [ -n "$assignee" ]; then + gh_cmd="$gh_cmd --assignee \"$assignee\"" + fi + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + # Execute command and extract issue number + local result + result=$(eval "$gh_cmd" 2>/dev/null) + + # Clean up temp file + [ -n "$body" ] && rm -f "$body_file" 2>/dev/null + + if [ $? -eq 0 ] && [ -n "$result" ]; then + # Extract issue number from URL + echo "$result" | grep -oE '[0-9]+$' + return 0 + else + echo "" + return 1 + fi +} + +# Update a GitHub issue +# Usage: update_github_issue "issue_number" ["title"] ["body"] ["labels"] +# Returns: 0 on success, 1 on failure +update_github_issue() { + local issue_number="$1" + local title="$2" + local body="$3" + local labels="$4" + local repo="${5:-}" + + if [ -z "$issue_number" ]; then + echo "Error: Issue number required" >&2 + return 1 + fi + + # Build gh issue edit command + local gh_cmd="gh issue edit \"$issue_number\"" + + if [ -n "$title" ]; then + gh_cmd="$gh_cmd --title \"$title\"" + fi + + if [ -n "$body" ]; then + # Create temp file for body + local body_file="/tmp/gh_issue_body_$$" + echo "$body" > "$body_file" + gh_cmd="$gh_cmd --body-file \"$body_file\"" + fi + + if [ -n "$labels" ]; then + gh_cmd="$gh_cmd --add-label \"$labels\"" + fi + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + # Execute command + eval "$gh_cmd" >/dev/null 2>&1 + local result=$? + + # Clean up temp file + [ -n "$body" ] && rm -f "$body_file" 2>/dev/null + + return $result +} + +# Get GitHub issue data as JSON +# Usage: get_github_issue "issue_number" ["repo"] +# Returns: JSON data or empty on failure +get_github_issue() { + local issue_number="$1" + local repo="${2:-}" + + if [ -z "$issue_number" ]; then + echo "Error: Issue number required" >&2 + return 1 + fi + + local gh_cmd="gh issue view \"$issue_number\" --json number,title,body,state,labels,updatedAt,createdAt" + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + eval "$gh_cmd" 2>/dev/null +} + +# Post a comment to a GitHub issue +# Usage: post_github_comment "issue_number" "comment_body" ["repo"] +# Returns: 0 on success, 1 on failure +post_github_comment() { + local issue_number="$1" + local comment_body="$2" + local repo="${3:-}" + + if [ -z "$issue_number" ] || [ -z "$comment_body" ]; then + echo "Error: Issue number and comment body required" >&2 + return 1 + fi + + # Create temp file for comment body + local body_file="/tmp/gh_comment_body_$$" + echo "$comment_body" > "$body_file" + + local gh_cmd="gh issue comment \"$issue_number\" --body-file \"$body_file\"" + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + eval "$gh_cmd" >/dev/null 2>&1 + local result=$? + + # Clean up temp file + rm -f "$body_file" 2>/dev/null + + return $result +} + +# Close a GitHub issue with optional comment +# Usage: close_github_issue "issue_number" ["closing_comment"] ["repo"] +# Returns: 0 on success, 1 on failure +close_github_issue() { + local issue_number="$1" + local closing_comment="$2" + local repo="${3:-}" + + if [ -z "$issue_number" ]; then + echo "Error: Issue number required" >&2 + return 1 + fi + + # Post comment if provided + if [ -n "$closing_comment" ]; then + post_github_comment "$issue_number" "$closing_comment" "$repo" + fi + + # Close the issue + local gh_cmd="gh issue close \"$issue_number\"" + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + eval "$gh_cmd" >/dev/null 2>&1 +} + +# Reopen a GitHub issue with optional comment +# Usage: reopen_github_issue "issue_number" ["reopening_comment"] ["repo"] +# Returns: 0 on success, 1 on failure +reopen_github_issue() { + local issue_number="$1" + local reopening_comment="$2" + local repo="${3:-}" + + if [ -z "$issue_number" ]; then + echo "Error: Issue number required" >&2 + return 1 + fi + + # Reopen the issue + local gh_cmd="gh issue reopen \"$issue_number\"" + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + eval "$gh_cmd" >/dev/null 2>&1 + local result=$? + + # Post comment if provided + if [ -n "$reopening_comment" ] && [ $result -eq 0 ]; then + post_github_comment "$issue_number" "$reopening_comment" "$repo" + fi + + return $result +} + +# List GitHub issues with specific criteria +# Usage: list_github_issues ["state"] ["labels"] ["limit"] ["repo"] +# Returns: JSON array of issues +list_github_issues() { + local state="${1:-all}" + local labels="$2" + local limit="${3:-100}" + local repo="$4" + + local gh_cmd="gh issue list --state \"$state\" --limit $limit --json number,title,state,labels,updatedAt,createdAt" + + if [ -n "$labels" ]; then + gh_cmd="$gh_cmd --label \"$labels\"" + fi + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + eval "$gh_cmd" 2>/dev/null +} + +# Extract issue number from GitHub URL +# Usage: extract_issue_number_from_url "https://github.com/owner/repo/issues/123" +# Returns: Issue number or empty if not found +extract_issue_number_from_url() { + local url="$1" + + if [ -z "$url" ]; then + echo "" + return 1 + fi + + # Extract number from various GitHub URL formats + echo "$url" | grep -oE '[0-9]+$' +} + +# Check if a GitHub issue exists +# Usage: github_issue_exists "issue_number" ["repo"] +# Returns: 0 if exists, 1 if not +github_issue_exists() { + local issue_number="$1" + local repo="${2:-}" + + if [ -z "$issue_number" ]; then + return 1 + fi + + local gh_cmd="gh issue view \"$issue_number\" --json number" + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + eval "$gh_cmd" >/dev/null 2>&1 +} + +# Get all issues with a specific label +# Usage: get_issues_by_label "label_name" ["state"] ["repo"] +# Returns: JSON array of issues +get_issues_by_label() { + local label="$1" + local state="${2:-all}" + local repo="$3" + + if [ -z "$label" ]; then + echo "[]" + return 1 + fi + + list_github_issues "$state" "$label" 1000 "$repo" +} + +# Add labels to a GitHub issue +# Usage: add_github_labels "issue_number" "label1,label2" ["repo"] +# Returns: 0 on success, 1 on failure +add_github_labels() { + local issue_number="$1" + local labels="$2" + local repo="${3:-}" + + if [ -z "$issue_number" ] || [ -z "$labels" ]; then + echo "Error: Issue number and labels required" >&2 + return 1 + fi + + local gh_cmd="gh issue edit \"$issue_number\" --add-label \"$labels\"" + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + eval "$gh_cmd" >/dev/null 2>&1 +} + +# Remove labels from a GitHub issue +# Usage: remove_github_labels "issue_number" "label1,label2" ["repo"] +# Returns: 0 on success, 1 on failure +remove_github_labels() { + local issue_number="$1" + local labels="$2" + local repo="${3:-}" + + if [ -z "$issue_number" ] || [ -z "$labels" ]; then + echo "Error: Issue number and labels required" >&2 + return 1 + fi + + local gh_cmd="gh issue edit \"$issue_number\" --remove-label \"$labels\"" + + if [ -n "$repo" ]; then + gh_cmd="$gh_cmd --repo \"$repo\"" + fi + + eval "$gh_cmd" >/dev/null 2>&1 +} \ No newline at end of file diff --git a/ccpm/scripts/pm/blocked.sh b/ccpm/scripts/pm/blocked.sh index 5a9ab443c..584acfa62 100755 --- a/ccpm/scripts/pm/blocked.sh +++ b/ccpm/scripts/pm/blocked.sh @@ -13,15 +13,28 @@ for epic_dir in .claude/epics/*/; do [ -d "$epic_dir" ] || continue epic_name=$(basename "$epic_dir") - for task_file in "$epic_dir"[0-9]*.md; do + for task_file in "$epic_dir"/[0-9]*.md; do [ -f "$task_file" ] || continue # Check if task is open status=$(grep "^status:" "$task_file" | head -1 | sed 's/^status: *//') - [ "$status" != "open" ] && [ -n "$status" ] && continue + if [ "$status" != "open" ] && [ -n "$status" ]; then + continue + fi # Check for dependencies - deps=$(grep "^depends_on:" "$task_file" | head -1 | sed 's/^depends_on: *\[//' | sed 's/\]//' | sed 's/,/ /g') + # Extract dependencies from task file + deps_line=$(grep "^depends_on:" "$task_file" | head -1) + if [ -n "$deps_line" ]; then + deps=$(echo "$deps_line" | sed 's/^depends_on: *//') + deps=$(echo "$deps" | sed 's/^\[//' | sed 's/\]$//') + deps=$(echo "$deps" | sed 's/,/ /g') + # Trim whitespace and handle empty cases + deps=$(echo "$deps" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + [ -z "$deps" ] && deps="" + else + deps="" + fi if [ -n "$deps" ] && [ "$deps" != "depends_on:" ]; then task_name=$(grep "^name:" "$task_file" | head -1 | sed 's/^name: *//') diff --git a/ccpm/scripts/pm/clean.sh b/ccpm/scripts/pm/clean.sh new file mode 100755 index 000000000..cbc2a47c0 --- /dev/null +++ b/ccpm/scripts/pm/clean.sh @@ -0,0 +1,391 @@ +#!/bin/bash + +# System Cleanup Script +# Cleans up completed work and archives old epics + +# Source utility libraries +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +LIB_DIR="$(dirname "$SCRIPT_DIR")/lib" + +source "$LIB_DIR/error.sh" +source "$LIB_DIR/frontmatter.sh" +source "$LIB_DIR/datetime.sh" +source "$LIB_DIR/discovery.sh" + +set_strict_mode + +# Configuration +STALE_DAYS="${STALE_DAYS:-30}" +ARCHIVE_DIR=".claude/epics/.archived" + +# Parse command line arguments +DRY_RUN=false +VERBOSE=false + +while [[ $# -gt 0 ]]; do + case $1 in + --dry-run|-n) + DRY_RUN=true + shift + ;; + --verbose|-v) + VERBOSE=true + shift + ;; + --help|-h) + echo "Usage: $0 [--dry-run] [--verbose]" + echo " --dry-run Show what would be cleaned without doing it" + echo " --verbose Show detailed output" + exit 0 + ;; + *) + error_exit "Unknown option: $1" + ;; + esac +done + +# Validate prerequisites +validate_directory_structure + +# Initialize counters and lists +declare -a completed_epics=() +declare -a stale_progress_files=() +declare -a empty_directories=() +total_size=0 + +# Find completed epics +find_completed_epics() { + info "Scanning for completed epics..." + + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + # Skip already archived epics + if [[ "$epic_dir" == *"/.archived/"* ]]; then + continue + fi + + local epic_name + epic_name=$(basename "$epic_dir") + local epic_file="$epic_dir/epic.md" + + [ -f "$epic_file" ] || continue + + # Check if epic is completed + local epic_status + epic_status=$(get_frontmatter_field "$epic_file" "status" "open") + + if [ "$epic_status" = "completed" ]; then + # Check if all tasks are closed + local open_tasks + open_tasks=$(get_epic_task_count_by_status "$epic_name" "open") + + if [ "$open_tasks" -eq 0 ]; then + # Check last update time + local updated + updated=$(get_frontmatter_field "$epic_file" "updated") + + if [ -n "$updated" ]; then + local age_days + age_days=$(get_file_age_days "$epic_file" "updated") + + if [ "$age_days" -gt "$STALE_DAYS" ]; then + completed_epics+=("$epic_name:$age_days") + + # Calculate size + local dir_size + dir_size=$(du -sk "$epic_dir" 2>/dev/null | cut -f1) + total_size=$((total_size + dir_size)) + + [ "$VERBOSE" = true ] && echo " Found completed epic: $epic_name ($age_days days old, ${dir_size}KB)" + fi + fi + else + [ "$VERBOSE" = true ] && warning "Epic $epic_name marked complete but has $open_tasks open tasks" + fi + fi + done + + info "Found ${#completed_epics[@]} completed epics to archive" +} + +# Find stale progress files +find_stale_progress() { + info "Scanning for stale progress files..." + + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + local updates_dir="$epic_dir/updates" + [ -d "$updates_dir" ] || continue + + for issue_dir in "$updates_dir"/*/; do + [ -d "$issue_dir" ] || continue + + local issue_num + issue_num=$(basename "$issue_dir") + + # Check if corresponding task is closed and old + local task_file + task_file=$(find_task_file_for_issue "$issue_num" 2>/dev/null) + + if [ -n "$task_file" ] && [ -f "$task_file" ]; then + local task_status + task_status=$(get_frontmatter_field "$task_file" "status" "open") + + if [ "$task_status" = "closed" ]; then + local age_days + age_days=$(get_file_age_days "$task_file" "updated") + + if [ "$age_days" -gt "$STALE_DAYS" ]; then + stale_progress_files+=("$issue_dir") + + # Calculate size + local dir_size + dir_size=$(du -sk "$issue_dir" 2>/dev/null | cut -f1) + total_size=$((total_size + dir_size)) + + [ "$VERBOSE" = true ] && echo " Found stale progress: $issue_dir ($age_days days old)" + fi + fi + else + # Progress directory exists but no corresponding task - likely orphaned + stale_progress_files+=("$issue_dir") + local dir_size + dir_size=$(du -sk "$issue_dir" 2>/dev/null | cut -f1) + total_size=$((total_size + dir_size)) + + [ "$VERBOSE" = true ] && echo " Found orphaned progress: $issue_dir" + fi + done + done + + info "Found ${#stale_progress_files[@]} stale progress files" +} + +# Find empty directories +find_empty_directories() { + info "Scanning for empty directories..." + + while IFS= read -r -d '' dir; do + # Skip the base directories we need to keep + case "$dir" in + ".claude/epics"|".claude/epics/.archived"|".claude"*) + continue + ;; + esac + + empty_directories+=("$dir") + [ "$VERBOSE" = true ] && echo " Found empty directory: $dir" + done < <(find .claude/epics -type d -empty -print0 2>/dev/null) + + info "Found ${#empty_directories[@]} empty directories" +} + +# Show cleanup plan +show_cleanup_plan() { + echo "" + echo "๐Ÿงน Cleanup Plan" + echo "===============" + echo "" + + if [ ${#completed_epics[@]} -gt 0 ]; then + echo "Completed Epics to Archive:" + for epic_info in "${completed_epics[@]}"; do + local epic_name="${epic_info%:*}" + local days="${epic_info#*:}" + echo " $epic_name - Completed $days days ago" + done + echo "" + fi + + if [ ${#stale_progress_files[@]} -gt 0 ]; then + echo "Stale Progress to Remove:" + echo " ${#stale_progress_files[@]} progress files for closed issues" + if [ "$VERBOSE" = true ]; then + for progress_dir in "${stale_progress_files[@]}"; do + echo " $progress_dir" + done + fi + echo "" + fi + + if [ ${#empty_directories[@]} -gt 0 ]; then + echo "Empty Directories:" + for dir in "${empty_directories[@]}"; do + echo " $dir" + done + echo "" + fi + + if [ $total_size -gt 0 ]; then + echo "Space to Recover: ~${total_size}KB" + echo "" + fi + + if [ "$DRY_RUN" = true ]; then + echo "This is a dry run. No changes made." + return 0 + fi + + # Ask for confirmation if not dry run and items found + local total_items=$((${#completed_epics[@]} + ${#stale_progress_files[@]} + ${#empty_directories[@]})) + + if [ $total_items -eq 0 ]; then + success "System is already clean. Nothing to do." + return 0 + fi + + if ! confirm "Proceed with cleanup?" "n"; then + info "Cleanup cancelled by user" + return 0 + fi + + return 1 # Proceed with cleanup +} + +# Execute cleanup +execute_cleanup() { + echo "" + info "Starting cleanup operations..." + + # Create archive directory + mkdir -p "$ARCHIVE_DIR" + + local archived_count=0 + local removed_files=0 + local removed_dirs=0 + + # Archive completed epics + if [ ${#completed_epics[@]} -gt 0 ]; then + info "Archiving completed epics..." + + for epic_info in "${completed_epics[@]}"; do + local epic_name="${epic_info%:*}" + local epic_dir=".claude/epics/$epic_name" + local archive_path="$ARCHIVE_DIR/$epic_name" + + if [ -d "$epic_dir" ]; then + mv "$epic_dir" "$archive_path" + success "Archived: $epic_name" + archived_count=$((archived_count + 1)) + fi + done + fi + + # Remove stale progress files + if [ ${#stale_progress_files[@]} -gt 0 ]; then + info "Removing stale progress files..." + + for progress_dir in "${stale_progress_files[@]}"; do + if [ -d "$progress_dir" ]; then + rm -rf "$progress_dir" + removed_files=$((removed_files + 1)) + [ "$VERBOSE" = true ] && echo " Removed: $progress_dir" + fi + done + + success "Removed $removed_files stale progress directories" + fi + + # Remove empty directories + if [ ${#empty_directories[@]} -gt 0 ]; then + info "Removing empty directories..." + + for dir in "${empty_directories[@]}"; do + if [ -d "$dir" ] && [ -z "$(ls -A "$dir" 2>/dev/null)" ]; then + rmdir "$dir" 2>/dev/null && { + removed_dirs=$((removed_dirs + 1)) + [ "$VERBOSE" = true ] && echo " Removed: $dir" + } + fi + done + + success "Removed $removed_dirs empty directories" + fi + + # Create archive log + if [ $archived_count -gt 0 ]; then + create_archive_log "$archived_count" + fi + + # Show final summary + echo "" + success "Cleanup Complete" + echo "" + echo "Archived:" + echo " $archived_count completed epics" + echo "" + echo "Removed:" + echo " $removed_files stale files" + echo " $removed_dirs empty directories" + echo "" + echo "Space recovered: ${total_size}KB" + echo "" + echo "System is clean and organized." +} + +# Create archive log +create_archive_log() { + local archived_count="$1" + local log_file="$ARCHIVE_DIR/archive-log.md" + local current_date + current_date=$(get_current_iso_timestamp) + + # Append to existing log or create new one + { + if [ ! -f "$log_file" ]; then + echo "# Archive Log" + echo "" + fi + + echo "## $(format_timestamp_display "$current_date")" + echo "- Archived: $archived_count completed epics" + echo "- Removed: ${#stale_progress_files[@]} stale progress files" + echo "- Cleaned: ${#empty_directories[@]} empty directories" + echo "- Space recovered: ${total_size}KB" + echo "" + + # List archived epics + if [ ${#completed_epics[@]} -gt 0 ]; then + echo "### Archived Epics:" + for epic_info in "${completed_epics[@]}"; do + local epic_name="${epic_info%:*}" + local days="${epic_info#*:}" + echo "- $epic_name (completed $days days ago)" + done + echo "" + fi + + } >> "$log_file" + + info "Archive log updated: $log_file" +} + +# Main execution +main() { + echo "๐Ÿงน CCPM System Cleanup" + echo "=====================" + echo "" + + if [ "$DRY_RUN" = true ]; then + info "Running in DRY-RUN mode - no changes will be made" + echo "" + fi + + # Perform scans + find_completed_epics + find_stale_progress + find_empty_directories + + # Show plan and get confirmation + if show_cleanup_plan; then + return 0 # Dry run or nothing to do + fi + + # Execute cleanup + execute_cleanup +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/ccpm/scripts/pm/close-epic.sh b/ccpm/scripts/pm/close-epic.sh new file mode 100755 index 000000000..2fd4b9a6c --- /dev/null +++ b/ccpm/scripts/pm/close-epic.sh @@ -0,0 +1,459 @@ +#!/bin/bash + +# Epic Closing Script +# Marks an epic as complete when all tasks are done + +# Source utility libraries +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +LIB_DIR="$(dirname "$SCRIPT_DIR")/lib" + +source "$LIB_DIR/error.sh" +source "$LIB_DIR/frontmatter.sh" +source "$LIB_DIR/datetime.sh" +source "$LIB_DIR/discovery.sh" +source "$LIB_DIR/github.sh" + +set_strict_mode + +# Main function to close an epic +# Usage: close_epic "epic_name" [--archive] +close_epic() { + local epic_name="$1" + local should_archive=false + + # Parse options + while [[ $# -gt 1 ]]; do + case $2 in + --archive|-a) + should_archive=true + shift + ;; + *) + error_exit "Unknown option: $2" + ;; + esac + done + + if [ -z "$epic_name" ]; then + error_exit "Epic name is required" + fi + + validate_epic_name "$epic_name" + validate_github_auth + + info "Closing epic: $epic_name" + + # Step 1: Verify all tasks are complete + verify_all_tasks_complete "$epic_name" + + # Step 2: Update epic status + update_epic_status "$epic_name" + + # Step 3: Update PRD status if linked + update_linked_prd_status "$epic_name" + + # Step 4: Close epic on GitHub + close_epic_on_github "$epic_name" + + # Step 5: Calculate epic duration + local epic_duration + epic_duration=$(calculate_epic_duration "$epic_name") + + # Step 6: Show completion summary + show_completion_summary "$epic_name" "$epic_duration" + + # Step 7: Offer to archive + if [ "$should_archive" = true ] || confirm "Archive completed epic?" "n"; then + archive_epic "$epic_name" + fi + + success "Epic closed: $epic_name" + echo "" + echo "Next epic: Run /pm:next to see priority work" +} + +# Verify all tasks in the epic are closed +verify_all_tasks_complete() { + local epic_name="$1" + + info "Verifying all tasks are complete..." + + local open_tasks=() + + while IFS= read -r task_file; do + local task_status + task_status=$(get_frontmatter_field "$task_file" "status" "open") + + if [ "$task_status" != "closed" ]; then + local task_name + task_name=$(get_frontmatter_field "$task_file" "name" "$(basename "$task_file" .md)") + open_tasks+=("$(basename "$task_file" .md): $task_name") + fi + done < <(get_epic_task_files "$epic_name") + + if [ ${#open_tasks[@]} -gt 0 ]; then + echo "โŒ Cannot close epic. Open tasks remain:" + for task in "${open_tasks[@]}"; do + echo " - $task" + done + echo "" + echo "Close all tasks first or run: /pm:status $epic_name" + exit 1 + fi + + success "All tasks are complete" +} + +# Update epic status to completed +update_epic_status() { + local epic_name="$1" + + info "Updating epic status..." + + local epic_file=".claude/epics/$epic_name/epic.md" + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + # Update epic frontmatter + update_frontmatter_bulk "$epic_file" \ + "status:completed" \ + "progress:100" \ + "updated:$current_timestamp" \ + "completed:$current_timestamp" + + success "Epic status updated to completed" +} + +# Update linked PRD status +update_linked_prd_status() { + local epic_name="$1" + + local epic_file=".claude/epics/$epic_name/epic.md" + local prd_name + prd_name=$(get_frontmatter_field "$epic_file" "prd") + + if [ -n "$prd_name" ]; then + info "Updating linked PRD status..." + + local prd_file=".claude/prds/$prd_name.md" + + if [ -f "$prd_file" ]; then + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + update_frontmatter_bulk "$prd_file" \ + "status:complete" \ + "updated:$current_timestamp" \ + "implemented:$current_timestamp" + + success "PRD status updated: $prd_name" + else + warning "Linked PRD file not found: $prd_file" + fi + fi +} + +# Close epic on GitHub +close_epic_on_github() { + local epic_name="$1" + + info "Closing epic on GitHub..." + + local epic_file=".claude/epics/$epic_name/epic.md" + local epic_github_url + epic_github_url=$(get_frontmatter_field "$epic_file" "github") + + if [ -z "$epic_github_url" ]; then + warning "Epic has no GitHub URL - cannot close on GitHub" + return 0 + fi + + local epic_issue_number + epic_issue_number=$(extract_issue_number_from_url "$epic_github_url") + + if [ -z "$epic_issue_number" ]; then + warning "Could not extract epic issue number from URL: $epic_github_url" + return 0 + fi + + local total_tasks + total_tasks=$(get_epic_task_count "$epic_name") + + local closing_comment="โœ… Epic completed - all $total_tasks tasks done + +This epic has been successfully completed with all tasks closed. + +๐ŸŽ‰ Ready for deployment and review." + + if close_github_issue "$epic_issue_number" "$closing_comment"; then + success "Epic closed on GitHub" + else + error_exit "Failed to close epic on GitHub" + fi +} + +# Calculate epic duration +calculate_epic_duration() { + local epic_name="$1" + + local epic_file=".claude/epics/$epic_name/epic.md" + local created_timestamp + local completed_timestamp + + created_timestamp=$(get_frontmatter_field "$epic_file" "created") + completed_timestamp=$(get_frontmatter_field "$epic_file" "completed") + + if [ -n "$created_timestamp" ] && [ -n "$completed_timestamp" ]; then + local created_ts + local completed_ts + + created_ts=$(iso_to_timestamp "$created_timestamp") + completed_ts=$(iso_to_timestamp "$completed_timestamp") + + if [ "$created_ts" != "0" ] && [ "$completed_ts" != "0" ]; then + local duration_seconds=$((completed_ts - created_ts)) + local duration_days=$((duration_seconds / 86400)) + + echo "$duration_days days" + return 0 + fi + fi + + echo "unknown" +} + +# Show completion summary +show_completion_summary() { + local epic_name="$1" + local duration="$2" + + local total_tasks + total_tasks=$(get_epic_task_count "$epic_name") + + echo "" + echo "๐Ÿ“Š Epic Completion Summary" + echo "==========================" + echo "" + echo "Epic: $epic_name" + echo "Tasks completed: $total_tasks" + echo "Duration: $duration" + echo "" +} + +# Archive epic to .archived directory +archive_epic() { + local epic_name="$1" + + info "Archiving epic..." + + local source_dir=".claude/epics/$epic_name" + local archive_dir=".claude/epics/.archived" + local target_dir="$archive_dir/$epic_name" + + # Create archive directory + mkdir -p "$archive_dir" + + # Check if target already exists + if [ -d "$target_dir" ]; then + local timestamp + timestamp=$(date +"%Y%m%d-%H%M%S") + target_dir="${target_dir}-${timestamp}" + warning "Archive target exists, using: $target_dir" + fi + + # Move epic to archive + mv "$source_dir" "$target_dir" + + # Create archive summary + create_archive_summary "$epic_name" "$target_dir" + + success "Archived to $target_dir" +} + +# Create archive summary +create_archive_summary() { + local epic_name="$1" + local archive_path="$2" + + local summary_file="$archive_path/ARCHIVE_INFO.md" + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + { + echo "# Archive Information" + echo "" + echo "**Epic:** $epic_name" + echo "**Archived:** $(format_timestamp_display "$current_timestamp")" + echo "**Status:** Completed" + echo "" + + # Get epic details + local epic_file="$archive_path/epic.md" + if [ -f "$epic_file" ]; then + local prd_name + local created_date + local completed_date + + prd_name=$(get_frontmatter_field "$epic_file" "prd" "N/A") + created_date=$(get_frontmatter_field "$epic_file" "created") + completed_date=$(get_frontmatter_field "$epic_file" "completed") + + echo "**Original PRD:** $prd_name" + + if [ -n "$created_date" ]; then + echo "**Started:** $(format_timestamp_display "$created_date")" + fi + + if [ -n "$completed_date" ]; then + echo "**Completed:** $(format_timestamp_display "$completed_date")" + fi + + echo "" + fi + + # List completed tasks + echo "## Tasks Completed" + echo "" + + local task_count=0 + for task_file in "$archive_path"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + local task_name + task_name=$(get_frontmatter_field "$task_file" "name" "$(basename "$task_file" .md)") + + echo "- $(basename "$task_file" .md): $task_name" + task_count=$((task_count + 1)) + done + + echo "" + echo "**Total Tasks:** $task_count" + echo "" + echo "---" + echo "" + echo "This epic was automatically archived by CCPM on completion." + + } > "$summary_file" + + info "Archive summary created: $summary_file" +} + +# Reopen an epic (unarchive and set status back to active) +reopen_epic() { + local epic_name="$1" + + if [ -z "$epic_name" ]; then + error_exit "Epic name is required" + fi + + info "Reopening epic: $epic_name" + + # Check if epic is archived + local archived_path=".claude/epics/.archived/$epic_name" + + if [ -d "$archived_path" ]; then + info "Unarchiving epic from .archived directory..." + + local target_path=".claude/epics/$epic_name" + + if [ -d "$target_path" ]; then + error_exit "Epic directory already exists: $target_path" + fi + + mv "$archived_path" "$target_path" + success "Epic unarchived" + else + # Epic might just be marked as completed + validate_epic_name "$epic_name" + fi + + # Update epic status back to active + local epic_file=".claude/epics/$epic_name/epic.md" + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + # Get current progress (might not be 100% if tasks were reopened) + local actual_progress + actual_progress=$(get_epic_progress "$epic_name") + + update_frontmatter_bulk "$epic_file" \ + "status:active" \ + "progress:$actual_progress" \ + "updated:$current_timestamp" + + # Remove completed timestamp if it exists + if grep -q "^completed:" "$epic_file"; then + # Remove the completed line + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' '/^completed:/d' "$epic_file" + else + sed -i '/^completed:/d' "$epic_file" + fi + fi + + # Reopen epic on GitHub if it exists + local epic_github_url + epic_github_url=$(get_frontmatter_field "$epic_file" "github") + + if [ -n "$epic_github_url" ]; then + local epic_issue_number + epic_issue_number=$(extract_issue_number_from_url "$epic_github_url") + + if [ -n "$epic_issue_number" ]; then + local reopening_comment="๐Ÿ”„ Epic reopened for additional work + +Status: In Progress (${actual_progress}% complete)" + + if reopen_github_issue "$epic_issue_number" "$reopening_comment"; then + success "Epic reopened on GitHub" + else + warning "Failed to reopen epic on GitHub" + fi + fi + fi + + success "Epic reopened: $epic_name" + echo " Status: Active (${actual_progress}% complete)" + echo " Ready for continued development" +} + +# Main function for command-line usage +main() { + local command="$1" + + case "$command" in + "close") + shift + close_epic "$@" + ;; + "reopen") + shift + reopen_epic "$@" + ;; + "help"|"--help"|"-h"|"") + echo "Epic Management Script" + echo "=====================" + echo "" + echo "Usage: $0 [options]" + echo "" + echo "Commands:" + echo " close [--archive] Close an epic when all tasks are complete" + echo " reopen Reopen a closed epic for additional work" + echo "" + echo "Options:" + echo " --archive, -a Automatically archive after closing" + echo "" + echo "Examples:" + echo " $0 close user-auth --archive" + echo " $0 reopen user-auth" + ;; + *) + error_exit "Unknown command: $command. Use 'help' for usage information." + ;; + esac +} + +# Only run main if script is executed directly (not sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/close-issue.sh b/ccpm/scripts/pm/close-issue.sh new file mode 100755 index 000000000..6a9ddf0ec --- /dev/null +++ b/ccpm/scripts/pm/close-issue.sh @@ -0,0 +1,439 @@ +#!/bin/bash + +# Issue Closing Script +# Marks an issue as complete and closes it on GitHub + +# Source utility libraries +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +LIB_DIR="$(dirname "$SCRIPT_DIR")/lib" + +source "$LIB_DIR/error.sh" +source "$LIB_DIR/frontmatter.sh" +source "$LIB_DIR/datetime.sh" +source "$LIB_DIR/discovery.sh" +source "$LIB_DIR/github.sh" + +set_strict_mode + +# Main function to close an issue +# Usage: close_issue "issue_number" ["completion_notes"] +close_issue() { + local issue_number="$1" + local completion_notes="$2" + + if [ -z "$issue_number" ]; then + error_exit "Issue number is required" + fi + + validate_issue_number "$issue_number" + validate_github_auth + + info "Closing issue #$issue_number..." + + # Step 1: Find local task file + local task_file + task_file=$(find_task_file_for_issue "$issue_number") + + if [ -z "$task_file" ] || [ ! -f "$task_file" ]; then + error_exit "No local task found for issue #$issue_number" + fi + + local epic_name + epic_name=$(find_epic_containing_issue "$issue_number") + + if [ -z "$epic_name" ]; then + error_exit "Could not determine epic for issue #$issue_number" + fi + + info "Found task file: $task_file (epic: $epic_name)" + + # Step 2: Update local status + update_local_task_status "$task_file" "$completion_notes" + + # Step 3: Update progress file if it exists + update_progress_file "$epic_name" "$issue_number" "$completion_notes" + + # Step 4: Close on GitHub + close_github_issue_with_comment "$issue_number" "$completion_notes" + + # Step 5: Update epic task list on GitHub + update_epic_task_list "$epic_name" "$issue_number" + + # Step 6: Update epic progress + update_epic_progress "$epic_name" + + # Final output + local epic_progress + epic_progress=$(get_epic_progress "$epic_name") + local total_tasks + total_tasks=$(get_epic_task_count "$epic_name") + local closed_tasks + closed_tasks=$(get_epic_task_count_by_status "$epic_name" "closed") + + success "Closed issue #$issue_number" + echo " Local: Task marked complete" + echo " GitHub: Issue closed & epic updated" + echo " Epic progress: ${epic_progress}% (${closed_tasks}/${total_tasks} tasks complete)" + echo "" + echo "Next: Run /pm:next for next priority task" +} + +# Update local task status +update_local_task_status() { + local task_file="$1" + local completion_notes="$2" + + info "Updating local task status..." + + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + # Update task frontmatter + update_frontmatter_bulk "$task_file" \ + "status:closed" \ + "updated:$current_timestamp" + + # Add completion notes to task if provided + if [ -n "$completion_notes" ]; then + # Append completion note to the task file + { + echo "" + echo "## Completion Notes" + echo "" + echo "$completion_notes" + echo "" + echo "Completed: $(format_timestamp_display "$current_timestamp")" + } >> "$task_file" + fi + + success "Local task status updated" +} + +# Update progress file +update_progress_file() { + local epic_name="$1" + local issue_number="$2" + local completion_notes="$3" + + local progress_file=".claude/epics/$epic_name/updates/$issue_number/progress.md" + + if [ -f "$progress_file" ]; then + info "Updating progress file..." + + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + # Update progress frontmatter + update_frontmatter_bulk "$progress_file" \ + "completion:100" \ + "last_sync:$current_timestamp" + + # Add completion note + { + echo "" + echo "## Final Update" + echo "" + echo "โœ… Task completed" + echo "" + if [ -n "$completion_notes" ]; then + echo "$completion_notes" + echo "" + fi + echo "Completed at: $(format_timestamp_display "$current_timestamp")" + } >> "$progress_file" + + success "Progress file updated" + fi +} + +# Close GitHub issue with comment +close_github_issue_with_comment() { + local issue_number="$1" + local completion_notes="$2" + + info "Closing GitHub issue..." + + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + # Prepare closing comment + local closing_comment="โœ… Task completed" + + if [ -n "$completion_notes" ]; then + closing_comment="$closing_comment + +$completion_notes" + fi + + closing_comment="$closing_comment + +--- +Closed at: $(format_timestamp_display "$current_timestamp")" + + # Close issue with comment + if close_github_issue "$issue_number" "$closing_comment"; then + success "GitHub issue closed" + else + error_exit "Failed to close GitHub issue #$issue_number" + fi +} + +# Update epic task list on GitHub +update_epic_task_list() { + local epic_name="$1" + local issue_number="$2" + + info "Updating epic task list on GitHub..." + + # Get epic issue number + local epic_file=".claude/epics/$epic_name/epic.md" + local epic_github_url + epic_github_url=$(get_frontmatter_field "$epic_file" "github") + + if [ -z "$epic_github_url" ]; then + warning "Epic has no GitHub URL - cannot update task list" + return 0 + fi + + local epic_issue_number + epic_issue_number=$(extract_issue_number_from_url "$epic_github_url") + + if [ -z "$epic_issue_number" ]; then + warning "Could not extract epic issue number from URL: $epic_github_url" + return 0 + fi + + # Get current epic body + local epic_data + epic_data=$(get_github_issue "$epic_issue_number") + + if [ -z "$epic_data" ]; then + warning "Could not fetch epic issue #$epic_issue_number" + return 0 + fi + + # Extract current body + local current_body + current_body=$(echo "$epic_data" | jq -r '.body // ""') + + if [ -z "$current_body" ]; then + warning "Epic issue has no body to update" + return 0 + fi + + # Create temp file with updated body + local temp_body="/tmp/epic_body_$$" + echo "$current_body" > "$temp_body" + + # Check off this task in the body + # Look for patterns like: - [ ] #123 or - [ ] Task description #123 + sed -i.bak "s/- \[ \] \(.*\)#$issue_number\b/- [x] \\1#$issue_number/" "$temp_body" + + # Update epic issue + if update_github_issue "$epic_issue_number" "" "$(cat "$temp_body")"; then + success "Updated epic progress on GitHub" + else + warning "Failed to update epic task list on GitHub" + fi + + # Cleanup + rm -f "$temp_body" "$temp_body.bak" +} + +# Update epic progress +update_epic_progress() { + local epic_name="$1" + + info "Updating epic progress..." + + local epic_file=".claude/epics/$epic_name/epic.md" + local total_tasks + local closed_tasks + local progress + + total_tasks=$(get_epic_task_count "$epic_name") + closed_tasks=$(get_epic_task_count_by_status "$epic_name" "closed") + + if [ "$total_tasks" -gt 0 ]; then + progress=$((closed_tasks * 100 / total_tasks)) + else + progress=0 + fi + + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + # Update epic frontmatter + update_frontmatter_bulk "$epic_file" \ + "progress:$progress" \ + "updated:$current_timestamp" + + success "Epic progress updated: ${progress}%" +} + +# Reopen an issue +# Usage: reopen_issue "issue_number" ["reopening_reason"] +reopen_issue() { + local issue_number="$1" + local reopening_reason="$2" + + if [ -z "$issue_number" ]; then + error_exit "Issue number is required" + fi + + validate_issue_number "$issue_number" + validate_github_auth + + info "Reopening issue #$issue_number..." + + # Find local task file + local task_file + task_file=$(find_task_file_for_issue "$issue_number") + + if [ -z "$task_file" ] || [ ! -f "$task_file" ]; then + error_exit "No local task found for issue #$issue_number" + fi + + local epic_name + epic_name=$(find_epic_containing_issue "$issue_number") + + # Update local status to open + local current_timestamp + current_timestamp=$(get_current_iso_timestamp) + + update_frontmatter_bulk "$task_file" \ + "status:open" \ + "updated:$current_timestamp" + + # Reopen on GitHub + local reopening_comment="๐Ÿ”„ Task reopened" + + if [ -n "$reopening_reason" ]; then + reopening_comment="$reopening_comment + +$reopening_reason" + fi + + reopening_comment="$reopening_comment + +--- +Reopened at: $(format_timestamp_display "$current_timestamp")" + + if reopen_github_issue "$issue_number" "$reopening_comment"; then + success "Reopened issue #$issue_number" + + # Update epic progress + if [ -n "$epic_name" ]; then + update_epic_progress "$epic_name" + fi + + # Update epic task list (uncheck the box) + if [ -n "$epic_name" ]; then + update_epic_task_list_reopen "$epic_name" "$issue_number" + fi + + echo " Local: Task marked as open" + echo " GitHub: Issue reopened" + echo "" + echo "Task is now available for work." + else + error_exit "Failed to reopen GitHub issue #$issue_number" + fi +} + +# Update epic task list when reopening (uncheck the box) +update_epic_task_list_reopen() { + local epic_name="$1" + local issue_number="$2" + + info "Updating epic task list (unchecking completed task)..." + + # Get epic issue number + local epic_file=".claude/epics/$epic_name/epic.md" + local epic_github_url + epic_github_url=$(get_frontmatter_field "$epic_file" "github") + + if [ -z "$epic_github_url" ]; then + warning "Epic has no GitHub URL - cannot update task list" + return 0 + fi + + local epic_issue_number + epic_issue_number=$(extract_issue_number_from_url "$epic_github_url") + + if [ -z "$epic_issue_number" ]; then + warning "Could not extract epic issue number" + return 0 + fi + + # Get current epic body + local epic_data + epic_data=$(get_github_issue "$epic_issue_number") + + if [ -z "$epic_data" ]; then + warning "Could not fetch epic issue #$epic_issue_number" + return 0 + fi + + local current_body + current_body=$(echo "$epic_data" | jq -r '.body // ""') + + if [ -z "$current_body" ]; then + return 0 + fi + + # Create temp file with updated body + local temp_body="/tmp/epic_body_$$" + echo "$current_body" > "$temp_body" + + # Uncheck this task in the body + sed -i.bak "s/- \[x\] \(.*\)#$issue_number\b/- [ ] \\1#$issue_number/" "$temp_body" + + # Update epic issue + if update_github_issue "$epic_issue_number" "" "$(cat "$temp_body")"; then + success "Updated epic task list on GitHub" + else + warning "Failed to update epic task list on GitHub" + fi + + # Cleanup + rm -f "$temp_body" "$temp_body.bak" +} + +# Main function for command-line usage +main() { + local command="$1" + + case "$command" in + "close") + shift + close_issue "$@" + ;; + "reopen") + shift + reopen_issue "$@" + ;; + "help"|"--help"|"-h"|"") + echo "Issue Management Script" + echo "======================" + echo "" + echo "Usage: $0 [completion_notes]" + echo "" + echo "Commands:" + echo " close [notes] Close an issue with optional completion notes" + echo " reopen [reason] Reopen a closed issue with optional reason" + echo "" + echo "Examples:" + echo " $0 close 1234 'Implemented authentication system with JWT tokens'" + echo " $0 reopen 1234 'Found a bug, needs additional work'" + ;; + *) + error_exit "Unknown command: $command. Use 'help' for usage information." + ;; + esac +} + +# Only run main if script is executed directly (not sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-list.sh b/ccpm/scripts/pm/epic-list.sh index 71ce6a67e..945b4d32a 100755 --- a/ccpm/scripts/pm/epic-list.sh +++ b/ccpm/scripts/pm/epic-list.sh @@ -3,8 +3,15 @@ echo "Getting epics..." echo "" echo "" -[ ! -d ".claude/epics" ] && echo "๐Ÿ“ No epics directory found. Create your first epic with: /pm:prd-parse " && exit 0 -[ -z "$(ls -d .claude/epics/*/ 2>/dev/null)" ] && echo "๐Ÿ“ No epics found. Create your first epic with: /pm:prd-parse " && exit 0 +if [ ! -d ".claude/epics" ]; then + echo "๐Ÿ“ No epics directory found. Create your first epic with: /pm:prd-parse " + exit 0 +fi +epic_dirs=$(ls -d .claude/epics/*/ 2>/dev/null || true) +if [ -z "$epic_dirs" ]; then + echo "๐Ÿ“ No epics found. Create your first epic with: /pm:prd-parse " + exit 0 +fi echo "๐Ÿ“š Project Epics" echo "================" @@ -31,7 +38,7 @@ for dir in .claude/epics/*/; do [ -z "$p" ] && p="0%" # Count tasks - t=$(ls "$dir"[0-9]*.md 2>/dev/null | wc -l) + t=$(ls "$dir"/[0-9]*.md 2>/dev/null | wc -l) # Format output with GitHub issue number if available if [ -n "$g" ]; then diff --git a/ccpm/scripts/pm/epic-merge/cleanup-worktree.sh b/ccpm/scripts/pm/epic-merge/cleanup-worktree.sh new file mode 100644 index 000000000..17d6d3b57 --- /dev/null +++ b/ccpm/scripts/pm/epic-merge/cleanup-worktree.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Post-merge cleanup operations + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +# Push to remote +git push origin main + +# Clean up worktree +git worktree remove "../epic-$ARGUMENTS" +echo "โœ… Worktree removed: ../epic-$ARGUMENTS" + +# Delete branch +git branch -d "epic/$ARGUMENTS" +git push origin --delete "epic/$ARGUMENTS" 2>/dev/null || true + +# Archive epic locally +mkdir -p .claude/epics/archived/ +mv ".claude/epics/$ARGUMENTS" ".claude/epics/archived/" +echo "โœ… Epic archived: .claude/epics/archived/$ARGUMENTS" \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-merge/close-github-issues.sh b/ccpm/scripts/pm/epic-merge/close-github-issues.sh new file mode 100644 index 000000000..c9f7a506a --- /dev/null +++ b/ccpm/scripts/pm/epic-merge/close-github-issues.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Close related GitHub issues + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +# Extract epic issue number +epic_github_line=$(grep 'github:' ".claude/epics/archived/$ARGUMENTS/epic.md" 2>/dev/null || true) +if [ -n "$epic_github_line" ]; then + epic_issue=$(echo "$epic_github_line" | grep -oE '[0-9]+$' || true) +else + epic_issue="" +fi + +# Close epic issue +if [ -n "$epic_issue" ]; then + gh issue close "$epic_issue" -c "Epic completed and merged to main" + echo "โœ… Closed epic issue #$epic_issue" +fi + +# Close task issues +for task_file in ".claude/epics/archived/$ARGUMENTS"/[0-9]*.md; do + [ -f "$task_file" ] || continue + # Extract task issue number + task_github_line=$(grep 'github:' "$task_file" 2>/dev/null || true) + if [ -n "$task_github_line" ]; then + issue_num=$(echo "$task_github_line" | grep -oE '[0-9]+$' || true) + else + issue_num="" + fi + if [ -n "$issue_num" ]; then + gh issue close "$issue_num" -c "Completed in epic merge" + echo "โœ… Closed task issue #$issue_num" + fi +done \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-merge/execute-merge.sh b/ccpm/scripts/pm/epic-merge/execute-merge.sh new file mode 100644 index 000000000..7d3faa2c7 --- /dev/null +++ b/ccpm/scripts/pm/epic-merge/execute-merge.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# Execute the epic merge operation + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +# Return to main repository (assume we're in a worktree) +cd "$(git rev-parse --show-superproject-working-tree)" || cd "../$(basename "$(pwd)" | sed 's/^epic-//')" || exit 1 + +# Ensure main is up to date +git checkout main +git pull origin main + +# Generate feature list +feature_list="" +if [ -d ".claude/epics/$ARGUMENTS" ]; then + cd ".claude/epics/$ARGUMENTS" || exit 1 + for task_file in [0-9]*.md; do + [ -f "$task_file" ] || continue + task_name=$(grep '^name:' "$task_file" | cut -d: -f2 | sed 's/^ *//') + feature_list="$feature_list\n- $task_name" + done + cd - > /dev/null +fi + +# Extract epic issue number +epic_issue="" +epic_github_line=$(grep 'github:' ".claude/epics/$ARGUMENTS/epic.md" 2>/dev/null || true) +if [ -n "$epic_github_line" ]; then + epic_issue=$(echo "$epic_github_line" | grep -oE '[0-9]+' || true) +fi + +# Build commit message +commit_message="Merge epic: $ARGUMENTS + +Completed features:$feature_list" + +if [ -n "$epic_issue" ]; then + commit_message="$commit_message + +Closes epic #$epic_issue" +fi + +# Attempt merge +echo "Merging epic/$ARGUMENTS to main..." +git merge "epic/$ARGUMENTS" --no-ff -m "$commit_message" + +if [ $? -eq 0 ]; then + echo "โœ… Merge completed successfully" +else + echo "โŒ Merge failed - conflicts detected" + exit 1 +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-merge/handle-conflicts.sh b/ccpm/scripts/pm/epic-merge/handle-conflicts.sh new file mode 100644 index 000000000..14b3e473d --- /dev/null +++ b/ccpm/scripts/pm/epic-merge/handle-conflicts.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Handle merge conflicts detection and guidance + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +# Check conflict status +git status + +echo " +โŒ Merge conflicts detected! + +Conflicts in: +$(git diff --name-only --diff-filter=U) + +Options: +1. Resolve manually: + - Edit conflicted files + - git add {files} + - git commit + +2. Abort merge: + git merge --abort + +3. Get help: + /pm:epic-resolve $ARGUMENTS + +Worktree preserved at: ../epic-$ARGUMENTS +" +exit 1 \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-merge/run-tests.sh b/ccpm/scripts/pm/epic-merge/run-tests.sh new file mode 100644 index 000000000..fdf8bc336 --- /dev/null +++ b/ccpm/scripts/pm/epic-merge/run-tests.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Run tests based on project type + +# Look for test commands based on project type +if [ -f package.json ]; then + npm test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f pom.xml ]; then + mvn test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f build.gradle ] || [ -f build.gradle.kts ]; then + ./gradlew test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f composer.json ]; then + ./vendor/bin/phpunit || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f *.sln ] || [ -f *.csproj ]; then + dotnet test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f Cargo.toml ]; then + cargo test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f go.mod ]; then + go test ./... || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f Gemfile ]; then + bundle exec rspec || bundle exec rake test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f pubspec.yaml ]; then + flutter test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f Package.swift ]; then + swift test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f CMakeLists.txt ]; then + cd build && ctest || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +elif [ -f Makefile ]; then + make test || echo "โš ๏ธ Tests failed. Continue anyway? (yes/no)" +else + echo "โ„น๏ธ No recognized test framework found, skipping tests" +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-merge/validate-worktree.sh b/ccpm/scripts/pm/epic-merge/validate-worktree.sh new file mode 100644 index 000000000..a7cb6d350 --- /dev/null +++ b/ccpm/scripts/pm/epic-merge/validate-worktree.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Pre-merge validation for epic worktree + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +# Verify worktree exists +if ! git worktree list | grep -q "epic-$ARGUMENTS"; then + echo "โŒ No worktree for epic: $ARGUMENTS" + exit 1 +fi + +# Navigate to worktree and check status +cd "../epic-$ARGUMENTS" || exit 1 + +# Check for uncommitted changes +if [[ $(git status --porcelain) ]]; then + echo "โš ๏ธ Uncommitted changes in worktree:" + git status --short + echo "Commit or stash changes before merging" + exit 1 +fi + +# Check branch status +git fetch origin +git status -sb + +echo "โœ… Worktree validation passed for epic: $ARGUMENTS" \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-start/check-analysis.sh b/ccpm/scripts/pm/epic-start/check-analysis.sh new file mode 100644 index 000000000..72a9b02bc --- /dev/null +++ b/ccpm/scripts/pm/epic-start/check-analysis.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Check and ensure analysis files exist for issues + +ARGUMENTS="$1" +ISSUE="$2" + +if [ -z "$ARGUMENTS" ] || [ -z "$ISSUE" ]; then + echo "โŒ Error: Epic name and issue number required" + echo "Usage: $0 " + exit 1 +fi + +# Check for analysis +if ! test -f ".claude/epics/$ARGUMENTS/${ISSUE}-analysis.md"; then + echo "โš ๏ธ Analysis missing for issue #${ISSUE}" + echo "Analysis needed before launching agents. Consider running analysis task first." + exit 1 +else + echo "โœ… Analysis found for issue #${ISSUE}" +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-start/create-execution-status.sh b/ccpm/scripts/pm/epic-start/create-execution-status.sh new file mode 100644 index 000000000..c7115f462 --- /dev/null +++ b/ccpm/scripts/pm/epic-start/create-execution-status.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Create or update execution status file + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +# Get current datetime +DATETIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Create execution status file +cat > ".claude/epics/$ARGUMENTS/execution-status.md" << EOF +--- +started: $DATETIME +branch: epic/$ARGUMENTS +--- + +# Execution Status + +## Active Agents +(Agents will be added here as they launch) + +## Queued Issues +(Issues waiting for dependencies will be listed here) + +## Completed +(Completed work will be tracked here) +EOF + +echo "โœ… Created execution status file: .claude/epics/$ARGUMENTS/execution-status.md" \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-start/manage-branch.sh b/ccpm/scripts/pm/epic-start/manage-branch.sh new file mode 100644 index 000000000..5fe00a46a --- /dev/null +++ b/ccpm/scripts/pm/epic-start/manage-branch.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Create or switch to epic branch + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +# Check for uncommitted changes +if [ -n "$(git status --porcelain)" ]; then + echo "โŒ You have uncommitted changes. Please commit or stash them before starting an epic." + exit 1 +fi + +# If branch doesn't exist, create it +if ! git branch -a | grep -q "epic/$ARGUMENTS"; then + git checkout main + git pull origin main + git checkout -b "epic/$ARGUMENTS" + git push -u origin "epic/$ARGUMENTS" + echo "โœ… Created branch: epic/$ARGUMENTS" +else + git checkout "epic/$ARGUMENTS" + git pull origin "epic/$ARGUMENTS" + echo "โœ… Using existing branch: epic/$ARGUMENTS" +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-start/preflight-checks.sh b/ccpm/scripts/pm/epic-start/preflight-checks.sh new file mode 100644 index 000000000..287c94563 --- /dev/null +++ b/ccpm/scripts/pm/epic-start/preflight-checks.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Preflight checks before starting an epic + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +# 1. Verify epic exists +if ! test -f ".claude/epics/$ARGUMENTS/epic.md"; then + echo "โŒ Epic not found. Run: /pm:prd-parse $ARGUMENTS" + exit 1 +fi + +# 2. Check GitHub sync (look for github: field) +if ! grep -q "^github:" ".claude/epics/$ARGUMENTS/epic.md"; then + echo "โŒ Epic not synced. Run: /pm:epic-sync $ARGUMENTS first" + exit 1 +fi + +# 3. Check for branch +if ! git branch -a | grep -q "epic/$ARGUMENTS"; then + echo "โ„น๏ธ Branch epic/$ARGUMENTS does not exist - will be created" +fi + +# 4. Check for uncommitted changes +if [ -n "$(git status --porcelain)" ]; then + echo "โŒ You have uncommitted changes. Please commit or stash them before starting an epic." + exit 1 +fi + +echo "โœ… Preflight checks passed for epic: $ARGUMENTS" \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-start/setup-monitoring.sh b/ccpm/scripts/pm/epic-start/setup-monitoring.sh new file mode 100644 index 000000000..049e0dc57 --- /dev/null +++ b/ccpm/scripts/pm/epic-start/setup-monitoring.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Set up monitoring guidance for launched epic + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +echo " +โœ… Agents launched successfully! + +Monitor progress: + /pm:epic-status $ARGUMENTS + +View branch changes: + git status + +Stop all agents: + /pm:epic-stop $ARGUMENTS + +Merge when complete: + /pm:epic-merge $ARGUMENTS +" \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-sync/build-local-inventory.sh b/ccpm/scripts/pm/epic-sync/build-local-inventory.sh new file mode 100755 index 000000000..18285cd8c --- /dev/null +++ b/ccpm/scripts/pm/epic-sync/build-local-inventory.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Build local file inventory with metadata +set -e + +EPIC_NAME="$1" + +if [ -z "$EPIC_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "๐Ÿ“ Building local file inventory..." + +# Create/clear local files list +> /tmp/epic-sync/local-files.txt + +# List all local files with metadata +for file_path in ".claude/epics/$EPIC_NAME"/*.md; do + [ -f "$file_path" ] || continue + + filename=$(basename "$file_path") + if [ "$filename" = "epic.md" ]; then + file_type="epic" + file_id="epic" + elif [[ "$filename" =~ ^[0-9]+\.md$ ]]; then + file_type="task" + file_id=$(basename "$filename" .md) + else + continue # Skip other files + fi + + # Extract metadata from frontmatter + updated_date=$(grep '^updated:' "$file_path" | sed 's/^updated: *//' || echo "1970-01-01T00:00:00Z") + status=$(grep '^status:' "$file_path" | sed 's/^status: *//' || echo "open") + github_url=$(grep '^github:' "$file_path" | sed 's/^github: *//' || true) + + if [ -n "$github_url" ]; then + github_issue=$(echo "$github_url" | grep -oE '[0-9]+$' || true) + else + github_issue="" + fi + + echo "$file_type:$file_id:$file_path:$updated_date:$status:$github_issue" >> /tmp/epic-sync/local-files.txt +done + +local_file_count=$(wc -l < /tmp/epic-sync/local-files.txt) +echo "๐Ÿ“Š Found $local_file_count local files" + +exit 0 \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-sync/fetch-github-issues.sh b/ccpm/scripts/pm/epic-sync/fetch-github-issues.sh new file mode 100755 index 000000000..5db09f60c --- /dev/null +++ b/ccpm/scripts/pm/epic-sync/fetch-github-issues.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Fetch all GitHub issues for an epic +set -e + +EPIC_NAME="$1" +REPO="$2" + +if [ -z "$EPIC_NAME" ] || [ -z "$REPO" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "๐Ÿ“ฅ Fetching GitHub issues for epic: $EPIC_NAME" + +# Create work directory +mkdir -p /tmp/epic-sync +> /tmp/epic-sync/github-issues.json + +# Get epic issue if it exists +epic_github_url=$(grep '^github:' ".claude/epics/$EPIC_NAME/epic.md" 2>/dev/null | sed 's/^github: *//' || true) +if [ -n "$epic_github_url" ]; then + epic_number=$(echo "$epic_github_url" | grep -oE '[0-9]+$') + echo "๐Ÿ“‹ Found epic issue: #$epic_number" + + # Fetch epic issue data + if gh issue view "$epic_number" --json number,title,body,state,updatedAt 2>/dev/null; then + gh issue view "$epic_number" --json number,title,body,state,updatedAt > /tmp/epic-sync/epic-issue.json + echo "epic:$epic_number" >> /tmp/epic-sync/github-issues.json + echo "$epic_number" > /tmp/epic-sync/epic-number.txt + else + echo "โš ๏ธ Epic issue #$epic_number no longer exists on GitHub" + echo "" > /tmp/epic-sync/epic-number.txt + fi +else + echo "๐Ÿ“ No existing epic issue found" + echo "" > /tmp/epic-sync/epic-number.txt +fi + +# Find all task issues by searching for epic label +echo "๐Ÿ” Searching for task issues with label 'epic:$EPIC_NAME'..." +gh issue list --repo "$REPO" --label "epic:$EPIC_NAME" --label "task" --state all --limit 100 \ + --json number,title,body,state,updatedAt > /tmp/epic-sync/task-issues.json + +# Extract issue numbers for reference +jq -r '.[].number' /tmp/epic-sync/task-issues.json | while read issue_num; do + echo "task:$issue_num" >> /tmp/epic-sync/github-issues.json +done + +task_issue_count=$(jq length /tmp/epic-sync/task-issues.json) +echo "๐Ÿ“Š Found $task_issue_count task issues on GitHub" + +exit 0 \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-sync/plan-sync-actions.sh b/ccpm/scripts/pm/epic-sync/plan-sync-actions.sh new file mode 100755 index 000000000..ce365630e --- /dev/null +++ b/ccpm/scripts/pm/epic-sync/plan-sync-actions.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Compare local and GitHub data to plan sync actions +set -e + +EPIC_NAME="$1" + +if [ -z "$EPIC_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "๐Ÿ” Analyzing sync requirements..." + +# Get epic number from previous step +epic_number=$(cat /tmp/epic-sync/epic-number.txt 2>/dev/null || true) + +# Clear sync actions file +> /tmp/epic-sync/sync-actions.txt + +# Process epic sync +if [ -n "$epic_number" ] && [ -f /tmp/epic-sync/epic-issue.json ]; then + # Epic exists on both sides - compare timestamps + gh_updated=$(jq -r '.updatedAt' /tmp/epic-sync/epic-issue.json) + local_updated=$(grep '^epic:' /tmp/epic-sync/local-files.txt | cut -d: -f4) + + # Convert ISO timestamps to seconds (cross-platform) + gh_timestamp=$(date -d "$gh_updated" "+%s" 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$gh_updated" "+%s" 2>/dev/null || echo "0") + local_timestamp=$(date -d "$local_updated" "+%s" 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$local_updated" "+%s" 2>/dev/null || echo "0") + + if [ "$gh_timestamp" -gt "$local_timestamp" ]; then + echo "update_local:epic:$epic_number" >> /tmp/epic-sync/sync-actions.txt + echo "๐Ÿ“ฅ Epic: GitHub newer โ†’ update local" + elif [ "$local_timestamp" -gt "$gh_timestamp" ]; then + echo "update_github:epic:$epic_number" >> /tmp/epic-sync/sync-actions.txt + echo "๐Ÿ“ค Epic: Local newer โ†’ update GitHub" + else + echo "โœ… Epic: Already in sync" + fi +elif [ -n "$epic_number" ]; then + # Epic exists on GitHub but not locally (shouldn't happen, but handle it) + echo "create_local:epic:$epic_number" >> /tmp/epic-sync/sync-actions.txt + echo "๐Ÿ“ฅ Epic: Create local from GitHub" +elif grep -q '^epic:' /tmp/epic-sync/local-files.txt; then + # Epic exists locally but not on GitHub + echo "create_github:epic:0" >> /tmp/epic-sync/sync-actions.txt + echo "๐Ÿ“ค Epic: Create on GitHub" +fi + +# Process task sync +echo "๐Ÿ” Analyzing task sync requirements..." + +# Check each GitHub task issue +if [ -s /tmp/epic-sync/task-issues.json ]; then + jq -r '.[] | "\(.number):\(.updatedAt)"' /tmp/epic-sync/task-issues.json | while IFS=: read gh_issue gh_updated; do + # Find corresponding local file + local_line=$(grep ":$gh_issue$" /tmp/epic-sync/local-files.txt || true) + + if [ -n "$local_line" ]; then + # Exists on both sides - compare timestamps + local_updated=$(echo "$local_line" | cut -d: -f4) + local_status=$(echo "$local_line" | cut -d: -f5) + + # Convert ISO timestamps to seconds (cross-platform) + gh_timestamp=$(date -d "$gh_updated" "+%s" 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$gh_updated" "+%s" 2>/dev/null || echo "0") + local_timestamp=$(date -d "$local_updated" "+%s" 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%SZ" "$local_updated" "+%s" 2>/dev/null || echo "0") + + if [ "$gh_timestamp" -gt "$local_timestamp" ]; then + echo "update_local:task:$gh_issue" >> /tmp/epic-sync/sync-actions.txt + elif [ "$local_timestamp" -gt "$gh_timestamp" ]; then + echo "update_github:task:$gh_issue:$local_status" >> /tmp/epic-sync/sync-actions.txt + fi + + # Always check if we need to post progress or close issue + echo "check_status:task:$gh_issue:$local_status" >> /tmp/epic-sync/sync-actions.txt + else + # Exists on GitHub but not locally + echo "create_local:task:$gh_issue" >> /tmp/epic-sync/sync-actions.txt + fi + done +fi + +# Check each local task file +grep '^task:' /tmp/epic-sync/local-files.txt 2>/dev/null | while IFS=: read file_type file_id file_path local_updated local_status github_issue; do + if [ -z "$github_issue" ] || ! jq -e ".[] | select(.number == $github_issue)" /tmp/epic-sync/task-issues.json >/dev/null 2>&1; then + # Exists locally but not on GitHub (or GitHub issue doesn't exist anymore) + echo "create_github:task:$file_id:$local_status" >> /tmp/epic-sync/sync-actions.txt + fi +done + +# Show sync plan +echo "" +echo "๐Ÿ“‹ Sync Plan:" +if [ -s /tmp/epic-sync/sync-actions.txt ]; then + update_local_count=$(grep -c "^update_local:" /tmp/epic-sync/sync-actions.txt || echo 0) + update_github_count=$(grep -c "^update_github:" /tmp/epic-sync/sync-actions.txt || echo 0) + create_local_count=$(grep -c "^create_local:" /tmp/epic-sync/sync-actions.txt || echo 0) + create_github_count=$(grep -c "^create_github:" /tmp/epic-sync/sync-actions.txt || echo 0) + status_check_count=$(grep -c "^check_status:" /tmp/epic-sync/sync-actions.txt || echo 0) + + echo " ๐Ÿ“ฅ Update local files: $update_local_count" + echo " ๐Ÿ“ค Update GitHub issues: $update_github_count" + echo " ๐Ÿ“ Create local files: $create_local_count" + echo " ๐Ÿ†• Create GitHub issues: $create_github_count" + echo " โœ… Status/progress checks: $status_check_count" +else + echo " โœ… Everything already in sync!" +fi + +exit 0 \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-sync/post-progress-reports.sh b/ccpm/scripts/pm/epic-sync/post-progress-reports.sh new file mode 100755 index 000000000..dacfee261 --- /dev/null +++ b/ccpm/scripts/pm/epic-sync/post-progress-reports.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# Post progress reports and manage issue states +set -e + +EPIC_NAME="$1" + +if [ -z "$EPIC_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "๐Ÿ“Š Posting progress reports and managing issue states..." + +# Calculate current epic progress +total_tasks=$(ls ".claude/epics/$EPIC_NAME"/[0-9]*.md 2>/dev/null | wc -l) +closed_tasks=$(grep -l '^status: closed' ".claude/epics/$EPIC_NAME"/[0-9]*.md 2>/dev/null | wc -l) + +if [ "$total_tasks" -gt 0 ]; then + progress=$((closed_tasks * 100 / total_tasks)) +else + progress=0 +fi + +# Save progress for other scripts +echo "$progress" > /tmp/epic-sync/progress.txt +echo "$total_tasks" > /tmp/epic-sync/total-tasks.txt +echo "$closed_tasks" > /tmp/epic-sync/closed-tasks.txt + +# Process status checks and progress updates +grep "^check_status:" /tmp/epic-sync/sync-actions.txt 2>/dev/null | while IFS=: read action item_type issue_num local_status; do + if [ "$item_type" = "task" ]; then + # Get current GitHub issue state + gh_state=$(gh issue view "$issue_num" --json state -q .state) + + # Post progress comment + current_date=$(date -u +"%Y-%m-%d %H:%M UTC") + + progress_comment="## ๐Ÿ“Š Sync Update - $current_date + +**Local Status:** $local_status +**Epic Progress:** $closed_tasks/$total_tasks tasks completed ($progress%) + +--- +*Updated via epic sync*" + + echo "$progress_comment" | gh issue comment "$issue_num" --body-file - + + # Manage issue state based on local status + if [ "$local_status" = "closed" ] && [ "$gh_state" = "open" ]; then + gh issue close "$issue_num" -c "๐ŸŽฏ Task completed - closing via sync + +Epic Progress: $closed_tasks/$total_tasks tasks completed ($progress%)" + echo "โœ… Closed GitHub issue #$issue_num (task completed)" + + elif [ "$local_status" = "open" ] && [ "$gh_state" = "closed" ]; then + gh issue reopen "$issue_num" -c "๐Ÿ”„ Task reopened - syncing status + +Epic Progress: $closed_tasks/$total_tasks tasks completed ($progress%)" + echo "๐Ÿ”„ Reopened GitHub issue #$issue_num (task reopened)" + else + echo "๐Ÿ“ Posted progress update to issue #$issue_num" + fi + fi +done + +# Update epic progress if epic exists +epic_number=$(cat /tmp/epic-sync/epic-number.txt 2>/dev/null || true) +if [ -n "$epic_number" ]; then + echo "๐Ÿ“ˆ Updating epic progress..." + + # Update epic frontmatter + current_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + epic_file=".claude/epics/$EPIC_NAME/epic.md" + + sed -i.bak "/^progress:/c\\progress: ${progress}%" "$epic_file" + sed -i.bak "/^updated:/c\\updated: $current_date" "$epic_file" + rm "${epic_file}.bak" + + # Post epic progress comment + epic_comment="## ๐Ÿ“ˆ Epic Progress Update - $(date -u +"%Y-%m-%d %H:%M UTC") + +**Progress:** $closed_tasks/$total_tasks tasks completed (**$progress%**) + +### Task Status: +$(for task_file in ".claude/epics/$EPIC_NAME"/[0-9]*.md; do + [ -f "$task_file" ] || continue + task_num=$(basename "$task_file" .md) + task_name=$(grep '^name:' "$task_file" | sed 's/^name: *//') + task_status=$(grep '^status:' "$task_file" | sed 's/^status: *//') + if [ "$task_status" = "closed" ]; then + echo "- โœ… #$task_num - $task_name" + else + echo "- โฌœ #$task_num - $task_name" + fi +done) + +--- +*Updated via bidirectional epic sync*" + + echo "$epic_comment" | gh issue comment "$epic_number" --body-file - + echo "๐Ÿ“Š Posted epic progress update to issue #$epic_number" + + # Close epic if 100% complete + if [ "$progress" -eq 100 ]; then + epic_state=$(gh issue view "$epic_number" --json state -q .state) + if [ "$epic_state" = "open" ]; then + gh issue close "$epic_number" -c "๐ŸŽ‰ Epic completed! All tasks finished. + +Final Status: $closed_tasks/$total_tasks tasks completed (100%)" + echo "๐ŸŽ‰ Closed epic issue #$epic_number (100% complete)" + fi + fi +fi + +exit 0 \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-sync/sync-github-issues.sh b/ccpm/scripts/pm/epic-sync/sync-github-issues.sh new file mode 100755 index 000000000..e97872b2e --- /dev/null +++ b/ccpm/scripts/pm/epic-sync/sync-github-issues.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# Update GitHub issues from local data +set -e + +EPIC_NAME="$1" +REPO="$2" +USE_SUBISSUES="$3" + +if [ -z "$EPIC_NAME" ] || [ -z "$REPO" ]; then + echo "Usage: $0 [use_subissues]" + exit 1 +fi + +echo "๐Ÿ“ค Syncing GitHub issues from local data..." + +# Get epic number from previous steps +epic_number=$(cat /tmp/epic-sync/epic-number.txt 2>/dev/null || true) + +# Update GitHub issues from local data +grep "^update_github:" /tmp/epic-sync/sync-actions.txt 2>/dev/null | while IFS=: read action item_type issue_num local_status; do + echo "๐Ÿ“ค Updating GitHub issue #$issue_num from local $item_type..." + + if [ "$item_type" = "epic" ]; then + # Update epic issue from epic.md + epic_file=".claude/epics/$EPIC_NAME/epic.md" + + # Extract title and body (strip frontmatter) + epic_title="Epic: $EPIC_NAME" + sed '1,/^---$/d; 1,/^---$/d' "$epic_file" > /tmp/epic-sync/epic-body.md + + # Update GitHub issue + gh issue edit "$issue_num" \ + --title "$epic_title" \ + --body-file /tmp/epic-sync/epic-body.md + + echo "โœ… Updated GitHub epic issue #$issue_num" + + elif [ "$item_type" = "task" ]; then + # Update task issue from task file + task_file=".claude/epics/$EPIC_NAME/${issue_num}.md" + + if [ -f "$task_file" ]; then + task_title=$(grep '^name:' "$task_file" | sed 's/^name: *//') + + # Strip frontmatter for body + sed '1,/^---$/d; 1,/^---$/d' "$task_file" > /tmp/epic-sync/task-body.md + + # Update GitHub issue content + gh issue edit "$issue_num" \ + --title "$task_title" \ + --body-file /tmp/epic-sync/task-body.md + + echo "โœ… Updated GitHub task issue #$issue_num" + fi + fi +done + +# Create GitHub issues from local files that don't have issues +grep "^create_github:" /tmp/epic-sync/sync-actions.txt 2>/dev/null | while IFS=: read action item_type file_id local_status; do + echo "๐Ÿ†• Creating GitHub issue from local $item_type..." + + if [ "$item_type" = "epic" ]; then + # Create epic issue from epic.md + epic_file=".claude/epics/$EPIC_NAME/epic.md" + epic_title="Epic: $EPIC_NAME" + + # Strip frontmatter for body + sed '1,/^---$/d; 1,/^---$/d' "$epic_file" > /tmp/epic-sync/epic-body.md + + # Determine epic type from content + if grep -qi "bug\\|fix\\|issue\\|problem\\|error" /tmp/epic-sync/epic-body.md; then + epic_type="bug" + else + epic_type="feature" + fi + + # Create epic issue + new_epic_number=$(gh issue create \ + --repo "$REPO" \ + --title "$epic_title" \ + --body-file /tmp/epic-sync/epic-body.md \ + --label "epic,epic:$EPIC_NAME,$epic_type" \ + --json number -q .number) + + # Update epic.md with GitHub URL + repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) + epic_url="https://github.com/$repo/issues/$new_epic_number" + current_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + sed -i.bak "/^github:/c\\github: $epic_url" "$epic_file" + sed -i.bak "/^updated:/c\\updated: $current_date" "$epic_file" + rm "${epic_file}.bak" + + # Update epic number for later scripts + echo "$new_epic_number" > /tmp/epic-sync/epic-number.txt + + echo "โœ… Created GitHub epic issue #$new_epic_number" + + elif [ "$item_type" = "task" ]; then + # Create task issue from task file + task_file=".claude/epics/$EPIC_NAME/${file_id}.md" + + if [ -f "$task_file" ]; then + task_title=$(grep '^name:' "$task_file" | sed 's/^name: *//') + task_status=$(grep '^status:' "$task_file" | sed 's/^status: *//') + + # Strip frontmatter for body + sed '1,/^---$/d; 1,/^---$/d' "$task_file" > /tmp/epic-sync/task-body.md + + # Get current epic number + current_epic_number=$(cat /tmp/epic-sync/epic-number.txt 2>/dev/null || true) + + # Create task issue (with sub-issue support if available) + if [ "$USE_SUBISSUES" = "true" ] && [ -n "$current_epic_number" ]; then + task_number=$(gh sub-issue create \ + --parent "$current_epic_number" \ + --title "$task_title" \ + --body-file /tmp/epic-sync/task-body.md \ + --label "task,epic:$EPIC_NAME" \ + --json number -q .number) + else + task_number=$(gh issue create \ + --repo "$REPO" \ + --title "$task_title" \ + --body-file /tmp/epic-sync/task-body.md \ + --label "task,epic:$EPIC_NAME" \ + --json number -q .number) + fi + + # Update task file with GitHub URL and rename if needed + repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) + task_url="https://github.com/$repo/issues/$task_number" + current_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Create new filename based on issue number + new_task_file=".claude/epics/$EPIC_NAME/${task_number}.md" + + # Update frontmatter + sed "/^github:/c\\github: $task_url" "$task_file" | \ + sed "/^updated:/c\\updated: $current_date" > "$new_task_file" + + # Remove old file if different name + [ "$task_file" != "$new_task_file" ] && rm "$task_file" + + # Close issue immediately if status is closed + if [ "$task_status" = "closed" ]; then + gh issue close "$task_number" -c "Task completed - closed during sync" + fi + + echo "โœ… Created GitHub task issue #$task_number (renamed to $(basename "$new_task_file"))" + fi + fi +done + +exit 0 \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-sync/sync-local-files.sh b/ccpm/scripts/pm/epic-sync/sync-local-files.sh new file mode 100755 index 000000000..92cadd8e7 --- /dev/null +++ b/ccpm/scripts/pm/epic-sync/sync-local-files.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# Update local files from GitHub data +set -e + +EPIC_NAME="$1" + +if [ -z "$EPIC_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "๐Ÿ“ฅ Syncing local files from GitHub..." + +# Update local files from GitHub data +grep "^update_local:" /tmp/epic-sync/sync-actions.txt 2>/dev/null | while IFS=: read action item_type issue_num; do + echo "๐Ÿ“ฅ Updating local $item_type from GitHub issue #$issue_num..." + + if [ "$item_type" = "epic" ]; then + # Update epic.md from GitHub issue + gh issue view "$issue_num" --json title,body,updatedAt > /tmp/epic-sync/current-issue.json + + gh_title=$(jq -r '.title' /tmp/epic-sync/current-issue.json) + gh_body=$(jq -r '.body' /tmp/epic-sync/current-issue.json) + gh_updated=$(jq -r '.updatedAt' /tmp/epic-sync/current-issue.json) + + # Read current frontmatter + epic_file=".claude/epics/$EPIC_NAME/epic.md" + + # Update frontmatter timestamp and keep body from GitHub + sed -i.bak "/^updated:/c\\updated: $gh_updated" "$epic_file" + + # Replace body content (preserve frontmatter) + awk ' + BEGIN { in_frontmatter = 0; frontmatter_count = 0 } + /^---$/ { + frontmatter_count++ + print + if (frontmatter_count == 2) in_frontmatter = 0 + else in_frontmatter = 1 + next + } + in_frontmatter { print } + !in_frontmatter && frontmatter_count < 2 { print } + !in_frontmatter && frontmatter_count >= 2 { exit } + ' "$epic_file" > /tmp/epic-sync/new-epic.md + + echo "" >> /tmp/epic-sync/new-epic.md + echo "$gh_body" >> /tmp/epic-sync/new-epic.md + + mv /tmp/epic-sync/new-epic.md "$epic_file" + rm "${epic_file}.bak" + + echo "โœ… Updated epic.md from GitHub" + + elif [ "$item_type" = "task" ]; then + # Update task file from GitHub issue + gh issue view "$issue_num" --json title,body,state,updatedAt > /tmp/epic-sync/current-issue.json + + gh_title=$(jq -r '.title' /tmp/epic-sync/current-issue.json) + gh_body=$(jq -r '.body' /tmp/epic-sync/current-issue.json) + gh_state=$(jq -r '.state' /tmp/epic-sync/current-issue.json) + gh_updated=$(jq -r '.updatedAt' /tmp/epic-sync/current-issue.json) + + # Convert GitHub state to local status + if [ "$gh_state" = "closed" ]; then + local_status="closed" + else + local_status="open" + fi + + # Find or create task file + task_file=".claude/epics/$EPIC_NAME/${issue_num}.md" + + if [ -f "$task_file" ]; then + # Update existing file + sed -i.bak "/^name:/c\\name: $gh_title" "$task_file" + sed -i.bak "/^status:/c\\status: $local_status" "$task_file" + sed -i.bak "/^updated:/c\\updated: $gh_updated" "$task_file" + + # Update body content (preserve frontmatter) + awk ' + BEGIN { in_frontmatter = 0; frontmatter_count = 0 } + /^---$/ { + frontmatter_count++ + print + if (frontmatter_count == 2) in_frontmatter = 0 + else in_frontmatter = 1 + next + } + in_frontmatter { print } + !in_frontmatter && frontmatter_count < 2 { print } + !in_frontmatter && frontmatter_count >= 2 { exit } + ' "$task_file" > /tmp/epic-sync/new-task.md + + echo "" >> /tmp/epic-sync/new-task.md + echo "$gh_body" >> /tmp/epic-sync/new-task.md + + mv /tmp/epic-sync/new-task.md "$task_file" + rm "${task_file}.bak" + fi + + echo "โœ… Updated task ${issue_num}.md from GitHub" + fi +done + +# Create local files from GitHub issues that don't exist locally +grep "^create_local:" /tmp/epic-sync/sync-actions.txt 2>/dev/null | while IFS=: read action item_type issue_num; do + echo "๐Ÿ“ Creating local $item_type file from GitHub issue #$issue_num..." + + if [ "$item_type" = "task" ]; then + gh issue view "$issue_num" --json title,body,state,updatedAt,labels > /tmp/epic-sync/current-issue.json + + gh_title=$(jq -r '.title' /tmp/epic-sync/current-issue.json) + gh_body=$(jq -r '.body' /tmp/epic-sync/current-issue.json) + gh_state=$(jq -r '.state' /tmp/epic-sync/current-issue.json) + gh_updated=$(jq -r '.updatedAt' /tmp/epic-sync/current-issue.json) + + # Convert GitHub state to local status + if [ "$gh_state" = "closed" ]; then + local_status="closed" + else + local_status="open" + fi + + # Create new task file + task_file=".claude/epics/$EPIC_NAME/${issue_num}.md" + current_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) + + cat > "$task_file" << EOF +--- +name: $gh_title +status: $local_status +parallel: false +depends_on: [] +conflicts_with: [] +github: https://github.com/$repo/issues/$issue_num +created: $current_date +updated: $gh_updated +--- + +$gh_body +EOF + + echo "โœ… Created task file: ${issue_num}.md" + fi +done + +exit 0 \ No newline at end of file diff --git a/ccpm/scripts/pm/epic-sync/worktree-and-mappings.sh b/ccpm/scripts/pm/epic-sync/worktree-and-mappings.sh new file mode 100755 index 000000000..531be95d5 --- /dev/null +++ b/ccpm/scripts/pm/epic-sync/worktree-and-mappings.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Create worktree and update mappings +set -e + +EPIC_NAME="$1" + +if [ -z "$EPIC_NAME" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "๐ŸŒณ Ensuring worktree exists..." + +# Check if worktree already exists +if git worktree list | grep -q "epic-$EPIC_NAME"; then + echo "๐Ÿ“ Worktree already exists: ../epic-$EPIC_NAME" +else + # Create worktree for epic + git checkout main 2>/dev/null + git pull origin main 2>/dev/null + git worktree add "../epic-$EPIC_NAME" -b "epic/$EPIC_NAME" 2>/dev/null + echo "โœ… Created worktree: ../epic-$EPIC_NAME" +fi + +# Update mapping file +echo "๐Ÿ“‹ Updating GitHub mapping file..." +repo=$(gh repo view --json nameWithOwner -q .nameWithOwner) +epic_number=$(cat /tmp/epic-sync/epic-number.txt 2>/dev/null || echo "TBD") +progress=$(cat /tmp/epic-sync/progress.txt 2>/dev/null || echo "0") +total_tasks=$(cat /tmp/epic-sync/total-tasks.txt 2>/dev/null || echo "0") +closed_tasks=$(cat /tmp/epic-sync/closed-tasks.txt 2>/dev/null || echo "0") + +cat > ".claude/epics/$EPIC_NAME/github-mapping.md" << EOF +# GitHub Issue Mapping - Epic: $EPIC_NAME + +## Epic +- Epic: #${epic_number} - https://github.com/${repo}/issues/${epic_number} + +## Tasks +EOF + +for task_file in ".claude/epics/$EPIC_NAME"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + task_num=$(basename "$task_file" .md) + task_name=$(grep '^name:' "$task_file" | sed 's/^name: *//') + task_status=$(grep '^status:' "$task_file" | sed 's/^status: *//') + + if [ "$task_status" = "closed" ]; then + status_icon="โœ…" + else + status_icon="โฌœ" + fi + + echo "- $status_icon #${task_num} - $task_name - https://github.com/${repo}/issues/${task_num}" >> ".claude/epics/$EPIC_NAME/github-mapping.md" +done + +cat >> ".claude/epics/$EPIC_NAME/github-mapping.md" << EOF + +--- +**Last Sync:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") +**Progress:** $closed_tasks/$total_tasks tasks completed ($progress%) +**Sync Mode:** Bidirectional (GitHub โ†” Local) +EOF + +echo "โœ… Updated GitHub mapping file" + +exit 0 \ No newline at end of file diff --git a/ccpm/scripts/pm/file-management.sh b/ccpm/scripts/pm/file-management.sh new file mode 100755 index 000000000..1d8ce780c --- /dev/null +++ b/ccpm/scripts/pm/file-management.sh @@ -0,0 +1,437 @@ +#!/bin/bash + +# File Management Utility Script +# Handles file renaming, organizing, and restructuring operations + +# Source utility libraries +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +LIB_DIR="$(dirname "$SCRIPT_DIR")/lib" + +source "$LIB_DIR/error.sh" +source "$LIB_DIR/frontmatter.sh" +source "$LIB_DIR/datetime.sh" +source "$LIB_DIR/discovery.sh" +source "$LIB_DIR/github.sh" + +set_strict_mode + +# Rename task files based on GitHub issue numbers +# Usage: rename_task_files_by_issue_number "epic_name" [--dry-run] +rename_task_files_by_issue_number() { + local epic_name="$1" + local dry_run=false + + # Parse options + while [[ $# -gt 1 ]]; do + case $2 in + --dry-run|-n) + dry_run=true + shift + ;; + *) + error_exit "Unknown option: $2" + ;; + esac + done + + if [ -z "$epic_name" ]; then + error_exit "Epic name is required" + fi + + validate_epic_name "$epic_name" + + local epic_dir=".claude/epics/$epic_name" + local renamed_count=0 + + info "Scanning task files in $epic_name for GitHub issue numbers..." + + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + # Extract issue number from GitHub URL in frontmatter + local github_url + github_url=$(get_frontmatter_field "$task_file" "github") + + if [ -n "$github_url" ]; then + local issue_num + issue_num=$(extract_issue_number_from_url "$github_url") + + if [ -n "$issue_num" ]; then + local current_filename + current_filename=$(basename "$task_file" .md) + local target_filename="$issue_num.md" + local target_path="$epic_dir/$target_filename" + + # Check if rename is needed + if [ "$current_filename.md" != "$target_filename" ]; then + if [ -f "$target_path" ] && [ "$target_path" != "$task_file" ]; then + warning "Target file already exists: $target_path (skipping $task_file)" + continue + fi + + if [ "$dry_run" = true ]; then + echo "Would rename: $task_file โ†’ $target_path" + else + mv "$task_file" "$target_path" + success "Renamed: $current_filename.md โ†’ $target_filename" + + # Update any references in dependencies + update_dependency_references "$epic_dir" "$current_filename" "$issue_num" + fi + + renamed_count=$((renamed_count + 1)) + fi + else + warning "Could not extract issue number from GitHub URL in $task_file" + fi + fi + done + + if [ "$dry_run" = true ]; then + info "Dry run complete. Would rename $renamed_count files." + else + success "Renamed $renamed_count task files based on issue numbers" + fi + + return 0 +} + +# Update dependency references after renaming +# Usage: update_dependency_references "epic_dir" "old_task_id" "new_task_id" +update_dependency_references() { + local epic_dir="$1" + local old_id="$2" + local new_id="$3" + + if ! type get_task_dependencies >/dev/null 2>&1; then + source "$LIB_DIR/dependencies.sh" + fi + + # Update all task files that reference the old ID + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + local deps + deps=$(get_task_dependencies "$task_file") + + if echo "$deps" | grep -q "\\b$old_id\\b"; then + # Replace old ID with new ID in dependencies + local new_deps + new_deps=$(echo "$deps" | sed "s/\\b$old_id\\b/$new_id/g") + + update_task_dependencies "$task_file" "$new_deps" + info "Updated dependencies in $(basename "$task_file"): $old_id โ†’ $new_id" + fi + done +} + +# Organize files by status (move closed tasks to subdirectory) +# Usage: organize_files_by_status "epic_name" [--dry-run] +organize_files_by_status() { + local epic_name="$1" + local dry_run=false + + # Parse options + while [[ $# -gt 1 ]]; do + case $2 in + --dry-run|-n) + dry_run=true + shift + ;; + *) + error_exit "Unknown option: $2" + ;; + esac + done + + if [ -z "$epic_name" ]; then + error_exit "Epic name is required" + fi + + validate_epic_name "$epic_name" + + local epic_dir=".claude/epics/$epic_name" + local closed_dir="$epic_dir/closed" + local moved_count=0 + + # Create closed directory if it doesn't exist + if [ "$dry_run" = false ]; then + mkdir -p "$closed_dir" + fi + + info "Organizing task files by status in $epic_name..." + + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + local task_status + task_status=$(get_frontmatter_field "$task_file" "status" "open") + + if [ "$task_status" = "closed" ]; then + local filename + filename=$(basename "$task_file") + local target_path="$closed_dir/$filename" + + if [ "$dry_run" = true ]; then + echo "Would move: $task_file โ†’ $target_path" + else + mv "$task_file" "$target_path" + success "Moved closed task: $filename โ†’ closed/" + fi + + moved_count=$((moved_count + 1)) + fi + done + + if [ "$dry_run" = true ]; then + info "Dry run complete. Would move $moved_count closed tasks." + else + success "Moved $moved_count closed tasks to closed/ subdirectory" + fi + + return 0 +} + +# Standardize file naming (ensure proper zero-padding and format) +# Usage: standardize_file_naming "epic_name" [--dry-run] +standardize_file_naming() { + local epic_name="$1" + local dry_run=false + + # Parse options + while [[ $# -gt 1 ]]; do + case $2 in + --dry-run|-n) + dry_run=true + shift + ;; + *) + error_exit "Unknown option: $2" + ;; + esac + done + + if [ -z "$epic_name" ]; then + error_exit "Epic name is required" + fi + + validate_epic_name "$epic_name" + + local epic_dir=".claude/epics/$epic_name" + local renamed_count=0 + + info "Standardizing file names in $epic_name..." + + for task_file in "$epic_dir"/[0-9]*.md; do + [ -f "$task_file" ] || continue + + local filename + filename=$(basename "$task_file" .md) + + # Check if this is a sequential number (not an issue number) + if [[ "$filename" =~ ^[0-9]{1,3}$ ]] && [ ${#filename} -lt 3 ]; then + # Needs zero-padding + local padded_filename + padded_filename=$(printf "%03d" "$((10#$filename))") + local target_path="$epic_dir/$padded_filename.md" + + if [ "$target_path" != "$task_file" ]; then + if [ -f "$target_path" ]; then + warning "Target file already exists: $target_path (skipping $task_file)" + continue + fi + + if [ "$dry_run" = true ]; then + echo "Would rename: $task_file โ†’ $target_path" + else + mv "$task_file" "$target_path" + success "Standardized: $filename.md โ†’ $padded_filename.md" + + # Update dependency references + update_dependency_references "$epic_dir" "$filename" "$padded_filename" + fi + + renamed_count=$((renamed_count + 1)) + fi + fi + done + + if [ "$dry_run" = true ]; then + info "Dry run complete. Would standardize $renamed_count filenames." + else + success "Standardized $renamed_count filenames" + fi + + return 0 +} + +# Batch rename tasks based on GitHub sync +# Usage: batch_rename_after_github_sync "epic_name" [--dry-run] +batch_rename_after_github_sync() { + local epic_name="$1" + + info "Processing batch rename after GitHub sync for: $epic_name" + + # First rename based on issue numbers + rename_task_files_by_issue_number "$epic_name" "$@" + + # Then standardize any remaining sequential numbers + standardize_file_naming "$epic_name" "$@" + + success "Batch rename complete for $epic_name" +} + +# Find and fix naming inconsistencies across all epics +# Usage: fix_naming_inconsistencies [--dry-run] +fix_naming_inconsistencies() { + local dry_run=false + + # Parse options + while [[ $# -gt 0 ]]; do + case $1 in + --dry-run|-n) + dry_run=true + shift + ;; + *) + error_exit "Unknown option: $1" + ;; + esac + done + + info "Scanning all epics for naming inconsistencies..." + + local total_fixes=0 + + for epic_dir in .claude/epics/*/; do + [ -d "$epic_dir" ] || continue + + # Skip archived epics + if [[ "$epic_dir" == *"/.archived/"* ]]; then + continue + fi + + local epic_name + epic_name=$(basename "$epic_dir") + + # Check if epic has tasks + if ls "$epic_dir"/[0-9]*.md >/dev/null 2>&1; then + info "Checking $epic_name..." + + # Fix issue-number based naming + local args=() + [ "$dry_run" = true ] && args+=("--dry-run") + + rename_task_files_by_issue_number "$epic_name" "${args[@]}" + standardize_file_naming "$epic_name" "${args[@]}" + fi + done + + success "Naming consistency check complete" +} + +# Create backup before bulk operations +# Usage: create_backup "epic_name" "operation_name" +create_backup() { + local epic_name="$1" + local operation="$2" + + if [ -z "$epic_name" ] || [ -z "$operation" ]; then + error_exit "Both epic name and operation name required for backup" + fi + + local backup_dir="/tmp/ccpm-backup-$(date +%s)" + local source_dir=".claude/epics/$epic_name" + + mkdir -p "$backup_dir" + cp -r "$source_dir" "$backup_dir/" + + info "Created backup for $operation: $backup_dir" + echo "$backup_dir" +} + +# Restore from backup +# Usage: restore_backup "backup_path" "epic_name" +restore_backup() { + local backup_path="$1" + local epic_name="$2" + + if [ -z "$backup_path" ] || [ -z "$epic_name" ]; then + error_exit "Both backup path and epic name required for restore" + fi + + if [ ! -d "$backup_path" ]; then + error_exit "Backup directory not found: $backup_path" + fi + + local target_dir=".claude/epics/$epic_name" + + if confirm "This will overwrite current epic data. Continue?" "n"; then + rm -rf "$target_dir" + cp -r "$backup_path/$epic_name" "$target_dir" + success "Restored epic from backup: $backup_path" + else + info "Restore cancelled" + fi +} + +# Main function for command-line usage +main() { + local command="$1" + shift + + case "$command" in + "rename-by-issue") + rename_task_files_by_issue_number "$@" + ;; + "organize-by-status") + organize_files_by_status "$@" + ;; + "standardize-names") + standardize_file_naming "$@" + ;; + "batch-rename") + batch_rename_after_github_sync "$@" + ;; + "fix-inconsistencies") + fix_naming_inconsistencies "$@" + ;; + "create-backup") + create_backup "$@" + ;; + "restore-backup") + restore_backup "$@" + ;; + "help"|"--help"|"-h"|"") + echo "File Management Utility" + echo "======================" + echo "" + echo "Usage: $0 [options]" + echo "" + echo "Commands:" + echo " rename-by-issue Rename task files based on GitHub issue numbers" + echo " organize-by-status Move closed tasks to subdirectory" + echo " standardize-names Ensure proper zero-padding for sequential files" + echo " batch-rename Complete rename process after GitHub sync" + echo " fix-inconsistencies Fix naming issues across all epics" + echo " create-backup Create backup before bulk operations" + echo " restore-backup Restore epic from backup" + echo "" + echo "Options:" + echo " --dry-run, -n Show what would be done without making changes" + echo "" + echo "Examples:" + echo " $0 batch-rename user-auth --dry-run" + echo " $0 fix-inconsistencies" + echo " $0 organize-by-status user-auth" + ;; + *) + error_exit "Unknown command: $command. Use 'help' for usage information." + ;; + esac +} + +# Only run main if script is executed directly (not sourced) +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/help.sh b/ccpm/scripts/pm/help.sh index bf825c4c9..8ffe2f8fe 100755 --- a/ccpm/scripts/pm/help.sh +++ b/ccpm/scripts/pm/help.sh @@ -22,14 +22,14 @@ echo " /pm:prd-status - Show PRD implementation status" echo "" echo "๐Ÿ“š Epic Commands" echo " /pm:epic-decompose - Break epic into task files" -echo " /pm:epic-sync - Push epic and tasks to GitHub" +echo " /pm:epic-sync - Bidirectional sync between local epics/tasks and GitHub issues" echo " /pm:epic-oneshot - Decompose and sync in one command" echo " /pm:epic-list - List all epics" echo " /pm:epic-show - Display epic and its tasks" echo " /pm:epic-status [name] - Show epic progress" echo " /pm:epic-close - Mark epic as complete" echo " /pm:epic-edit - Edit epic details" -echo " /pm:epic-refresh - Update epic progress from tasks" +echo "" echo " /pm:epic-start - Launch parallel agent execution" echo "" echo "๐Ÿ“ Issue Commands" diff --git a/ccpm/scripts/pm/issue-sync/calculate-epic-progress.sh b/ccpm/scripts/pm/issue-sync/calculate-epic-progress.sh new file mode 100644 index 000000000..72847c06a --- /dev/null +++ b/ccpm/scripts/pm/issue-sync/calculate-epic-progress.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Calculate and update epic progress based on completed tasks + +EPIC_NAME="$1" +if [ -z "$EPIC_NAME" ]; then + echo "โŒ Error: Epic name required" + exit 1 +fi + +if [ ! -d ".claude/epics/$EPIC_NAME" ]; then + echo "โŒ Epic directory not found: $EPIC_NAME" + exit 1 +fi + +# Count total tasks in epic directory +total_tasks=0 +closed_tasks=0 + +for task_file in ".claude/epics/$EPIC_NAME"/[0-9]*.md; do + [ -f "$task_file" ] || continue + total_tasks=$((total_tasks + 1)) + + # Check if task is closed + status=$(grep '^status:' "$task_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + if [ "$status" = "closed" ]; then + closed_tasks=$((closed_tasks + 1)) + fi +done + +if [ $total_tasks -eq 0 ]; then + echo "โ„น๏ธ No tasks found in epic: $EPIC_NAME" + exit 0 +fi + +# Calculate progress percentage +progress=$((closed_tasks * 100 / total_tasks)) + +# Update epic frontmatter +epic_file=".claude/epics/$EPIC_NAME/epic.md" +if [ -f "$epic_file" ]; then + # Extract current frontmatter values + name=$(grep '^name:' "$epic_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + status=$(grep '^status:' "$epic_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + created=$(grep '^created:' "$epic_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + prd=$(grep '^prd:' "$epic_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + github_url=$(grep '^github:' "$epic_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + + # Update status based on progress + if [ $progress -eq 100 ]; then + status="completed" + elif [ $progress -gt 0 ]; then + status="in-progress" + fi + + # Create updated frontmatter + { + echo "---" + echo "name: $name" + echo "status: $status" + echo "created: $created" + echo "progress: ${progress}%" + [ -n "$prd" ] && echo "prd: $prd" + [ -n "$github_url" ] && echo "github: $github_url" + echo "---" + # Add content after frontmatter + sed '1,/^---$/d; 1,/^---$/d' "$epic_file" + } > "$epic_file.tmp" && mv "$epic_file.tmp" "$epic_file" + + echo "โœ… Epic progress updated: $progress% ($closed_tasks/$total_tasks tasks completed)" +else + echo "โš ๏ธ Epic file not found: $epic_file" +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/issue-sync/check-repo-protection.sh b/ccpm/scripts/pm/issue-sync/check-repo-protection.sh new file mode 100644 index 000000000..ab01bfac7 --- /dev/null +++ b/ccpm/scripts/pm/issue-sync/check-repo-protection.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Repository protection check - ensure not syncing to CCPM template + +# Check remote origin for CCPM template repository +remote_url=$(git remote get-url origin 2>/dev/null || echo "") +if [[ "$remote_url" == *"automazeio/ccpm"* ]]; then + echo "โŒ ERROR: Cannot sync to CCPM template repository!" + echo "Update your remote: git remote set-url origin https://github.com/YOUR_USERNAME/YOUR_REPO.git" + exit 1 +fi + +echo "โœ… Repository protection check passed" \ No newline at end of file diff --git a/ccpm/scripts/pm/issue-sync/check-sync-timing.sh b/ccpm/scripts/pm/issue-sync/check-sync-timing.sh new file mode 100644 index 000000000..10cdd5f95 --- /dev/null +++ b/ccpm/scripts/pm/issue-sync/check-sync-timing.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Check last sync timing to prevent excessive syncing + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Issue number required" + exit 1 +fi + +# Find epic containing this issue +epic_found="" +for epic_dir in .claude/epics/*/; do + if [ -d "${epic_dir}updates/$ARGUMENTS/" ]; then + epic_found=$(basename "$epic_dir") + break + fi +done + +if [ -z "$epic_found" ]; then + echo "โŒ No updates directory found for issue #$ARGUMENTS" + exit 1 +fi + +progress_file=".claude/epics/$epic_found/updates/$ARGUMENTS/progress.md" + +if [ ! -f "$progress_file" ]; then + echo "โ„น๏ธ No previous sync found - proceeding with first sync" + exit 0 +fi + +# Extract last_sync from frontmatter +last_sync=$(grep '^last_sync:' "$progress_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + +if [ -z "$last_sync" ] || [ "$last_sync" = "null" ]; then + echo "โ„น๏ธ No previous sync timestamp - proceeding" + exit 0 +fi + +# Calculate time difference (basic check - 5 minutes = 300 seconds) +current_time=$(date -u +%s) +if command -v gdate >/dev/null 2>&1; then + # macOS with GNU coreutils + last_sync_time=$(gdate -d "$last_sync" +%s 2>/dev/null || echo "0") +else + # Linux date + last_sync_time=$(date -d "$last_sync" +%s 2>/dev/null || echo "0") +fi + +time_diff=$((current_time - last_sync_time)) + +if [ "$time_diff" -lt 300 ]; then + minutes_ago=$((time_diff / 60)) + echo "โš ๏ธ Recently synced $minutes_ago minutes ago. Force sync anyway? (yes/no)" + # In automated context, we'll proceed but warn +fi + +echo "โœ… Sync timing check passed" \ No newline at end of file diff --git a/ccpm/scripts/pm/issue-sync/post-comment.sh b/ccpm/scripts/pm/issue-sync/post-comment.sh new file mode 100644 index 000000000..a0d3a6e2e --- /dev/null +++ b/ccpm/scripts/pm/issue-sync/post-comment.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Post formatted update comment to GitHub issue + +ARGUMENTS="$1" +TEMP_FILE="$2" + +if [ -z "$ARGUMENTS" ] || [ -z "$TEMP_FILE" ]; then + echo "โŒ Error: Issue number and temp file path required" + echo "Usage: $0 " + exit 1 +fi + +if [ ! -f "$TEMP_FILE" ]; then + echo "โŒ Error: Comment file not found: $TEMP_FILE" + exit 1 +fi + +# Check comment size (GitHub limit: 65,536 characters) +comment_size=$(wc -c < "$TEMP_FILE") +if [ "$comment_size" -gt 65536 ]; then + echo "โš ๏ธ Comment exceeds GitHub limit (${comment_size} chars > 65,536)" + echo "Consider splitting into multiple comments or summarizing" + # Proceed anyway - let GitHub handle the truncation +fi + +# Post comment using GitHub CLI +if gh issue comment "$ARGUMENTS" --body-file "$TEMP_FILE"; then + echo "โœ… Comment posted successfully to issue #$ARGUMENTS" + # Clean up temp file + rm -f "$TEMP_FILE" +else + echo "โŒ Failed to post comment to issue #$ARGUMENTS" + echo "Comment saved in: $TEMP_FILE" + exit 1 +fi \ No newline at end of file diff --git a/ccpm/scripts/pm/issue-sync/preflight-validation.sh b/ccpm/scripts/pm/issue-sync/preflight-validation.sh new file mode 100644 index 000000000..3fd5ecd74 --- /dev/null +++ b/ccpm/scripts/pm/issue-sync/preflight-validation.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Preflight validation for issue sync + +ARGUMENTS="$1" +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Issue number required" + exit 1 +fi + +# 1. GitHub Authentication +if ! gh auth status >/dev/null 2>&1; then + echo "โŒ GitHub CLI not authenticated. Run: gh auth login" + exit 1 +fi + +# 2. Issue Validation +if ! gh issue view "$ARGUMENTS" --json state >/dev/null 2>&1; then + echo "โŒ Issue #$ARGUMENTS not found" + exit 1 +fi + +# Check issue state +issue_state=$(gh issue view "$ARGUMENTS" --json state --jq '.state') +if [ "$issue_state" = "CLOSED" ]; then + echo "โš ๏ธ Issue is closed but work incomplete" +fi + +# 3. Local Updates Check +epic_found="" +for epic_dir in .claude/epics/*/; do + if [ -d "${epic_dir}updates/$ARGUMENTS/" ]; then + epic_found=$(basename "$epic_dir") + break + fi +done + +if [ -z "$epic_found" ]; then + echo "โŒ No local updates found for issue #$ARGUMENTS. Run: /pm:issue-start $ARGUMENTS" + exit 1 +fi + +if [ ! -f ".claude/epics/$epic_found/updates/$ARGUMENTS/progress.md" ]; then + echo "โŒ No progress tracking found. Initialize with: /pm:issue-start $ARGUMENTS" + exit 1 +fi + +echo "โœ… Preflight validation passed for issue #$ARGUMENTS" +echo "Epic: $epic_found" \ No newline at end of file diff --git a/ccpm/scripts/pm/issue-sync/update-frontmatter.sh b/ccpm/scripts/pm/issue-sync/update-frontmatter.sh new file mode 100644 index 000000000..23d7c5ae8 --- /dev/null +++ b/ccpm/scripts/pm/issue-sync/update-frontmatter.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Update frontmatter in progress and task files after sync + +ARGUMENTS="$1" +COMPLETION="$2" + +if [ -z "$ARGUMENTS" ]; then + echo "โŒ Error: Issue number required" + exit 1 +fi + +# Default completion to 0 if not provided +if [ -z "$COMPLETION" ]; then + COMPLETION="0" +fi + +# Find epic containing this issue +epic_found="" +for epic_dir in .claude/epics/*/; do + if [ -d "${epic_dir}updates/$ARGUMENTS/" ]; then + epic_found=$(basename "$epic_dir") + break + fi +done + +if [ -z "$epic_found" ]; then + echo "โŒ No updates directory found for issue #$ARGUMENTS" + exit 1 +fi + +# Get current datetime +current_datetime=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Update progress.md frontmatter +progress_file=".claude/epics/$epic_found/updates/$ARGUMENTS/progress.md" +if [ -f "$progress_file" ]; then + # Extract current frontmatter values + started=$(grep '^started:' "$progress_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + + # Create updated frontmatter + { + echo "---" + echo "issue: $ARGUMENTS" + echo "started: $started" + echo "last_sync: $current_datetime" + echo "completion: ${COMPLETION}%" + echo "---" + # Add content after frontmatter + sed '1,/^---$/d; 1,/^---$/d' "$progress_file" + } > "$progress_file.tmp" && mv "$progress_file.tmp" "$progress_file" + + echo "โœ… Updated progress.md frontmatter" +fi + +# Find and update task file frontmatter +task_file="" +for task in ".claude/epics/$epic_found"/[0-9]*.md; do + if [ -f "$task" ] && grep -q "github.*$ARGUMENTS" "$task"; then + task_file="$task" + break + fi +done + +if [ -n "$task_file" ]; then + # Extract current frontmatter values + name=$(grep '^name:' "$task_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + created=$(grep '^created:' "$task_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + github_url=$(grep '^github:' "$task_file" | head -1 | cut -d: -f2- | sed 's/^ *//') + + # Determine status based on completion + if [ "$COMPLETION" = "100" ]; then + status="closed" + else + status="open" + fi + + # Create updated frontmatter + { + echo "---" + echo "name: $name" + echo "status: $status" + echo "created: $created" + echo "updated: $current_datetime" + echo "github: $github_url" + echo "---" + # Add content after frontmatter + sed '1,/^---$/d; 1,/^---$/d' "$task_file" + } > "$task_file.tmp" && mv "$task_file.tmp" "$task_file" + + echo "โœ… Updated task file frontmatter: $(basename "$task_file")" +fi + +echo "โœ… Frontmatter updates completed" \ No newline at end of file diff --git a/ccpm/scripts/pm/next.sh b/ccpm/scripts/pm/next.sh index 07cb1e2ce..a6e94facb 100755 --- a/ccpm/scripts/pm/next.sh +++ b/ccpm/scripts/pm/next.sh @@ -14,15 +14,27 @@ for epic_dir in .claude/epics/*/; do [ -d "$epic_dir" ] || continue epic_name=$(basename "$epic_dir") - for task_file in "$epic_dir"[0-9]*.md; do + for task_file in "$epic_dir"/[0-9]*.md; do [ -f "$task_file" ] || continue # Check if task is open status=$(grep "^status:" "$task_file" | head -1 | sed 's/^status: *//') - [ "$status" != "open" ] && [ -n "$status" ] && continue + if [ "$status" != "open" ] && [ -n "$status" ]; then + continue + fi # Check dependencies - deps=$(grep "^depends_on:" "$task_file" | head -1 | sed 's/^depends_on: *\[//' | sed 's/\]//') + # Extract dependencies from task file + deps_line=$(grep "^depends_on:" "$task_file" | head -1) + if [ -n "$deps_line" ]; then + deps=$(echo "$deps_line" | sed 's/^depends_on: *//') + deps=$(echo "$deps" | sed 's/^\[//' | sed 's/\]$//') + # Trim whitespace and handle empty cases + deps=$(echo "$deps" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + [ -z "$deps" ] && deps="" + else + deps="" + fi # If no dependencies or empty, task is available if [ -z "$deps" ] || [ "$deps" = "depends_on:" ]; then diff --git a/ccpm/scripts/pm/standup.sh b/ccpm/scripts/pm/standup.sh index 887be33e6..9992431e7 100755 --- a/ccpm/scripts/pm/standup.sh +++ b/ccpm/scripts/pm/standup.sh @@ -51,12 +51,24 @@ echo "โญ๏ธ Next Available Tasks:" count=0 for epic_dir in .claude/epics/*/; do [ -d "$epic_dir" ] || continue - for task_file in "$epic_dir"[0-9]*.md; do + for task_file in "$epic_dir"/[0-9]*.md; do [ -f "$task_file" ] || continue status=$(grep "^status:" "$task_file" | head -1 | sed 's/^status: *//') - [ "$status" != "open" ] && [ -n "$status" ] && continue + if [ "$status" != "open" ] && [ -n "$status" ]; then + continue + fi - deps=$(grep "^depends_on:" "$task_file" | head -1 | sed 's/^depends_on: *\[//' | sed 's/\]//') + # Extract dependencies from task file + deps_line=$(grep "^depends_on:" "$task_file" | head -1) + if [ -n "$deps_line" ]; then + deps=$(echo "$deps_line" | sed 's/^depends_on: *//') + deps=$(echo "$deps" | sed 's/^\[//' | sed 's/\]$//') + # Trim whitespace and handle empty cases + deps=$(echo "$deps" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + [ -z "$deps" ] && deps="" + else + deps="" + fi if [ -z "$deps" ] || [ "$deps" = "depends_on:" ]; then task_name=$(grep "^name:" "$task_file" | head -1 | sed 's/^name: *//') task_num=$(basename "$task_file" .md) diff --git a/ccpm/scripts/pm/validate.sh b/ccpm/scripts/pm/validate.sh index 1fe777181..a8b61386b 100755 --- a/ccpm/scripts/pm/validate.sh +++ b/ccpm/scripts/pm/validate.sh @@ -42,7 +42,18 @@ echo "๐Ÿ”— Reference Check:" for task_file in .claude/epics/*/[0-9]*.md; do [ -f "$task_file" ] || continue - deps=$(grep "^depends_on:" "$task_file" | head -1 | sed 's/^depends_on: *\[//' | sed 's/\]//' | sed 's/,/ /g') + # Extract dependencies from task file + deps_line=$(grep "^depends_on:" "$task_file" | head -1) + if [ -n "$deps_line" ]; then + deps=$(echo "$deps_line" | sed 's/^depends_on: *//') + deps=$(echo "$deps" | sed 's/^\[//' | sed 's/\]$//') + deps=$(echo "$deps" | sed 's/,/ /g') + # Trim whitespace and handle empty cases + deps=$(echo "$deps" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//') + [ -z "$deps" ] && deps="" + else + deps="" + fi if [ -n "$deps" ] && [ "$deps" != "depends_on:" ]; then epic_dir=$(dirname "$task_file") for dep in $deps; do @@ -54,7 +65,9 @@ for task_file in .claude/epics/*/[0-9]*.md; do fi done -[ $warnings -eq 0 ] && [ $errors -eq 0 ] && echo " โœ… All references valid" +if [ $warnings -eq 0 ] && [ $errors -eq 0 ]; then + echo " โœ… All references valid" +fi # Check frontmatter echo ""