From 1656879e2aa6521e7655c37fd2c35a1b14e1adb2 Mon Sep 17 00:00:00 2001 From: Kevin Quinn Date: Tue, 31 Mar 2026 11:42:56 +0100 Subject: [PATCH 1/3] Add docs-cherry-pick skill and docs-branch-audit skill Adds two new skills to docs-tools: - docs-cherry-pick: Intelligently cherry-pick documentation changes across branches - docs-branch-audit: Audit which files from a PR, commit, or branch exist in target branches Co-Authored-By: Claude Opus 4.6 --- .../skills/docs-branch-audit/SKILL.md | 113 ++++ .../docs-branch-audit/scripts/branch_audit.sh | 396 +++++++++++ .../docs-branch-audit/scripts/deep_audit.sh | 199 ++++++ .../skills/docs-cherry-pick/SKILL.md | 123 ++++ .../docs-cherry-pick/scripts/cherry_pick.sh | 637 ++++++++++++++++++ 5 files changed, 1468 insertions(+) create mode 100644 plugins/docs-tools/skills/docs-branch-audit/SKILL.md create mode 100755 plugins/docs-tools/skills/docs-branch-audit/scripts/branch_audit.sh create mode 100755 plugins/docs-tools/skills/docs-branch-audit/scripts/deep_audit.sh create mode 100644 plugins/docs-tools/skills/docs-cherry-pick/SKILL.md create mode 100755 plugins/docs-tools/skills/docs-cherry-pick/scripts/cherry_pick.sh diff --git a/plugins/docs-tools/skills/docs-branch-audit/SKILL.md b/plugins/docs-tools/skills/docs-branch-audit/SKILL.md new file mode 100644 index 00000000..fb944d4d --- /dev/null +++ b/plugins/docs-tools/skills/docs-branch-audit/SKILL.md @@ -0,0 +1,113 @@ +--- +name: docs-branch-audit +description: Audit which files from a PR, commit, or file list exist on target enterprise branches. Use this skill to plan cherry-pick backports by identifying which modules are applicable to each release. +author: Keith Quinn (kquinn@redhat.com) +allowed-tools: Bash, Read +--- + +# Branch audit skill + +Audit file existence and content compatibility across enterprise branches to support intelligent cherry-pick backporting. + +Given a source (PR URL, commit SHA, or file list) and one or more target branches, this skill reports which files exist on each branch and which would need to be excluded from a backport. + +## Usage + +```bash +# Audit a PR against target branches +bash ${SKILL_DIR}/scripts/branch_audit.sh \ + --pr https://github.com/openshift/openshift-docs/pull/106280 \ + --branches enterprise-4.17,enterprise-4.16 + +# Audit a commit +bash ${SKILL_DIR}/scripts/branch_audit.sh \ + --commit abc123def \ + --branches enterprise-4.17 + +# Audit from a file list +bash ${SKILL_DIR}/scripts/branch_audit.sh \ + --files /tmp/files-to-check.txt \ + --branches enterprise-4.17,enterprise-4.16 + +# JSON output for programmatic use +bash ${SKILL_DIR}/scripts/branch_audit.sh \ + --pr https://github.com/openshift/openshift-docs/pull/106280 \ + --branches enterprise-4.17 \ + --json + +# Deep audit — check content compatibility for included files +bash ${SKILL_DIR}/scripts/branch_audit.sh \ + --pr https://github.com/openshift/openshift-docs/pull/106280 \ + --branches enterprise-4.17 \ + --deep +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--pr ` | GitHub PR URL to get file list from | +| `--commit ` | Git commit SHA to get file list from | +| `--files ` | Text file with one file path per line | +| `--branches ` | Comma-separated list of target branches (required) | +| `--deep` | Run deep content comparison for included files (confidence levels, conditionals, structural differences) | +| `--json` | Output results as JSON | +| `--dry-run` | Show what would be checked without fetching branches | + +## Output + +### Text format (default) + +``` +=== Branch Audit: enterprise-4.17 === + +Include (22 files): + modules/cnf-about-collecting-ptp-data.adoc + modules/cnf-configuring-fifo-priority-scheduling-for-ptp.adoc + ... + +Exclude (5 files — not on branch): + modules/cnf-configuring-enhanced-log-filtering-for-linuxptp.adoc + modules/nw-ptp-t-bc-t-tsc-holdover.adoc + ... + +Summary: 22/27 files applicable to enterprise-4.17 +``` + +### Deep audit output + +``` +=== Deep Audit: enterprise-4.17 === + +[HIGH] modules/cnf-about-collecting-ptp-data.adoc + files-identical: File is identical on both branches + +[MEDIUM] modules/nw-ptp-installing-operator-web-console.adoc + moderate-divergence: 78 lines differ between branches + conditionals: 2 conditional directive(s) found + +[NEEDS-REVIEW] modules/nw-ptp-configuring-linuxptp-services-as-grandmaster-clock.adoc + large-divergence: 245 lines differ between branches + structure: heading count differs (source: 5, target: 3) + +=== Deep Audit Summary === + + Total files: 23 + High: 18 (changes will apply cleanly) + Medium: 3 (likely applies, review recommended) + Needs review: 2 (conflicts or large divergence expected) +``` + +## Confidence levels + +| Level | Meaning | Action | +|-------|---------|--------| +| **HIGH** | File identical or < 20 lines differ | Cherry-pick will apply cleanly | +| **MEDIUM** | 20-99 lines differ, or conditionals present | Likely applies, minor conflicts possible | +| **NEEDS-REVIEW** | 100+ lines differ, structural changes | Conflicts expected, manual review needed | + +## Prerequisites + +- Must be run from within a git repository that has the target branches available (e.g., `openshift-docs`) +- For PR mode: requires `GITHUB_TOKEN` environment variable +- For commit mode: the commit must exist in the local repository diff --git a/plugins/docs-tools/skills/docs-branch-audit/scripts/branch_audit.sh b/plugins/docs-tools/skills/docs-branch-audit/scripts/branch_audit.sh new file mode 100755 index 00000000..44707a76 --- /dev/null +++ b/plugins/docs-tools/skills/docs-branch-audit/scripts/branch_audit.sh @@ -0,0 +1,396 @@ +#!/bin/bash +# branch_audit.sh - Check which files from a source exist on target branches +# +# Usage: +# branch_audit.sh --pr --branches [--json] [--deep] +# branch_audit.sh --commit --branches [--json] [--deep] +# branch_audit.sh --files --branches [--json] [--deep] +# +# Requires: git, python3 (for PR mode with git_pr_reader.py) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$SCRIPT_DIR")" +PLUGIN_DIR="$(dirname "$(dirname "$SKILL_DIR")")" +GIT_REVIEW_API="${PLUGIN_DIR}/skills/git-pr-reader/scripts/git_pr_reader.py" + +# Defaults +PR_URL="" +COMMIT_SHA="" +FILE_LIST="" +BRANCHES="" +JSON_OUTPUT=false +DRY_RUN=false +DEEP=false + +usage() { + cat <<'USAGE' +Usage: + branch_audit.sh --pr --branches [--json] + branch_audit.sh --commit --branches [--json] + branch_audit.sh --files --branches [--json] + +Options: + --pr GitHub PR or GitLab MR URL + --commit Git commit SHA to get file list from + --files Text file with one file path per line + --branches Comma-separated list of target branches + --json Output results as JSON + --deep Run deep content comparison for included files + --dry-run Show what would be checked without fetching branches + +Examples: + branch_audit.sh --pr https://github.com/openshift/openshift-docs/pull/106280 \ + --branches enterprise-4.17,enterprise-4.16 + + branch_audit.sh --commit abc123def --branches enterprise-4.17 +USAGE + exit 1 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --pr) PR_URL="$2"; shift 2 ;; + --commit) COMMIT_SHA="$2"; shift 2 ;; + --files) FILE_LIST="$2"; shift 2 ;; + --branches) BRANCHES="$2"; shift 2 ;; + --json) JSON_OUTPUT=true; shift ;; + --deep) DEEP=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + -h|--help) usage ;; + *) echo "ERROR: Unknown option: $1"; usage ;; + esac +done + +# Validate inputs +if [[ -z "$BRANCHES" ]]; then + echo "ERROR: --branches is required" + usage +fi + +if [[ -z "$PR_URL" && -z "$COMMIT_SHA" && -z "$FILE_LIST" ]]; then + echo "ERROR: One of --pr, --commit, or --files is required" + usage +fi + +# Temporary files +CANDIDATE_FILES=$(mktemp /tmp/branch-audit-candidates.XXXXXX) +REPORT_FILE=$(mktemp /tmp/branch-audit-report.XXXXXX) +trap 'rm -f "$CANDIDATE_FILES" "$REPORT_FILE"' EXIT + +# Step 1: Build file list from source +echo "=== Building file list ===" >&2 + +if [[ -n "$PR_URL" ]]; then + echo "Source: PR $PR_URL" >&2 + PR_FILES_OK=false + + # Try git_pr_reader.py first + if [[ -f "$GIT_REVIEW_API" ]]; then + if python3 "$GIT_REVIEW_API" files "$PR_URL" 2>/dev/null | grep '\.adoc$' > "$CANDIDATE_FILES" && [[ -s "$CANDIDATE_FILES" ]]; then + PR_FILES_OK=true + else + echo "WARNING: git_pr_reader.py failed, trying gh CLI fallback..." >&2 + fi + fi + + # Fallback to gh CLI + # Unset GITHUB_TOKEN so gh uses keyring auth (an invalid GITHUB_TOKEN overrides valid keyring credentials) + if [[ "$PR_FILES_OK" = false ]] && command -v gh >/dev/null 2>&1; then + # Extract owner/repo and PR number from URL + # Supports: https://github.com/owner/repo/pull/123 + PR_NUMBER=$(echo "$PR_URL" | grep -oP '/pull/\K[0-9]+') + OWNER_REPO=$(echo "$PR_URL" | grep -oP 'github\.com/\K[^/]+/[^/]+') + if [[ -n "$PR_NUMBER" && -n "$OWNER_REPO" ]]; then + if GITHUB_TOKEN= GH_TOKEN= gh api "repos/${OWNER_REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' 2>/dev/null | grep '\.adoc$' > "$CANDIDATE_FILES" && [[ -s "$CANDIDATE_FILES" ]]; then + PR_FILES_OK=true + else + echo "WARNING: gh CLI also failed" >&2 + fi + fi + fi + + # Fallback to GitHub MCP tool file list extraction + if [[ "$PR_FILES_OK" = false ]]; then + echo "ERROR: Could not fetch PR file list. Check GITHUB_TOKEN or gh auth status." >&2 + exit 1 + fi + +elif [[ -n "$COMMIT_SHA" ]]; then + echo "Source: commit $COMMIT_SHA" >&2 + git diff-tree --no-commit-id --name-only -r "$COMMIT_SHA" | grep '\.adoc$' > "$CANDIDATE_FILES" || true + +elif [[ -n "$FILE_LIST" ]]; then + echo "Source: file list $FILE_LIST" >&2 + if [[ ! -f "$FILE_LIST" ]]; then + echo "ERROR: File list not found: $FILE_LIST" >&2 + exit 1 + fi + grep '\.adoc$' "$FILE_LIST" > "$CANDIDATE_FILES" || true +fi + +TOTAL_FILES=$(wc -l < "$CANDIDATE_FILES") +if [[ "$TOTAL_FILES" -eq 0 ]]; then + echo "ERROR: No .adoc files found from source" >&2 + exit 1 +fi + +echo "Found $TOTAL_FILES .adoc file(s) from source" >&2 +echo "" >&2 + +# Step 2: Fetch remote branches if needed +if [[ "$DRY_RUN" = false ]]; then + IFS=',' read -ra BRANCH_ARRAY <<< "$BRANCHES" + for branch in "${BRANCH_ARRAY[@]}"; do + branch=$(echo "$branch" | xargs) # trim whitespace + if ! git rev-parse --verify "remotes/upstream/$branch" >/dev/null 2>&1; then + echo "Fetching upstream/$branch..." >&2 + git fetch upstream "$branch" 2>/dev/null || { + echo "WARNING: Could not fetch upstream/$branch — trying origin/$branch" >&2 + git fetch origin "$branch" 2>/dev/null || { + echo "ERROR: Branch $branch not found on upstream or origin" >&2 + continue + } + } + fi + done +fi + +# Step 3: Check file existence on each branch +IFS=',' read -ra BRANCH_ARRAY <<< "$BRANCHES" + +if [[ "$JSON_OUTPUT" = true ]]; then + echo "{" + echo " \"source_files\": $TOTAL_FILES," + echo " \"branches\": {" +fi + +BRANCH_IDX=0 +for branch in "${BRANCH_ARRAY[@]}"; do + branch=$(echo "$branch" | xargs) # trim whitespace + BRANCH_IDX=$((BRANCH_IDX + 1)) + + INCLUDE_FILES=() + EXCLUDE_FILES=() + unset RENAMED_MAP 2>/dev/null || true + declare -A RENAMED_MAP # source_path → target_path for fuzzy matches + + # Determine the ref to check against + REF="" + if git rev-parse --verify "remotes/upstream/$branch" >/dev/null 2>&1; then + REF="remotes/upstream/$branch" + elif git rev-parse --verify "remotes/origin/$branch" >/dev/null 2>&1; then + REF="remotes/origin/$branch" + else + echo "WARNING: Branch $branch not found, skipping" >&2 + continue + fi + + # Build target branch file index for fuzzy matching (one-time per branch) + TARGET_FILE_INDEX=$(mktemp /tmp/branch-audit-index.XXXXXX) + git ls-tree -r --name-only "$REF" | grep '\.adoc$' > "$TARGET_FILE_INDEX" + + while IFS= read -r filepath; do + [[ -z "$filepath" ]] && continue + filepath=$(echo "$filepath" | xargs) # trim whitespace + + if git cat-file -e "${REF}:${filepath}" 2>/dev/null; then + INCLUDE_FILES+=("$filepath") + else + # Fuzzy filename search: look for similar basenames on target branch + BASENAME=$(basename "$filepath") + DIRPATH=$(dirname "$filepath") + FUZZY_MATCH=$(python3 -c " +import difflib, sys +basename = sys.argv[1] +candidates = [] +for line in sys.stdin: + line = line.strip() + if not line: + continue + candidates.append(line) +# Build basename-to-fullpath map +base_map = {} +for c in candidates: + b = c.rsplit('/', 1)[-1] + base_map.setdefault(b, []).append(c) +# Find close basename matches (cutoff 0.85 catches single-char typos) +close = difflib.get_close_matches(basename, list(base_map.keys()), n=1, cutoff=0.85) +if close: + # Prefer match in same directory, fall back to first match + paths = base_map[close[0]] + same_dir = [p for p in paths if p.startswith(sys.argv[2] + '/')] + print(same_dir[0] if same_dir else paths[0]) +" "$BASENAME" "$DIRPATH" < "$TARGET_FILE_INDEX" 2>/dev/null || true) + + if [[ -n "$FUZZY_MATCH" ]]; then + INCLUDE_FILES+=("$filepath") + RENAMED_MAP["$filepath"]="$FUZZY_MATCH" + echo " FUZZY MATCH: $filepath → $FUZZY_MATCH on $branch" >&2 + else + EXCLUDE_FILES+=("$filepath") + fi + fi + done < "$CANDIDATE_FILES" + + rm -f "$TARGET_FILE_INDEX" + + INCLUDE_COUNT=${#INCLUDE_FILES[@]} + EXCLUDE_COUNT=${#EXCLUDE_FILES[@]} + # Safe count for associative array under set -u + RENAMED_COUNT=0 + for _ in "${!RENAMED_MAP[@]}"; do RENAMED_COUNT=$((RENAMED_COUNT + 1)); done 2>/dev/null || true + + if [[ "$JSON_OUTPUT" = true ]]; then + # JSON output + [[ $BRANCH_IDX -gt 1 ]] && echo "," + echo " \"$branch\": {" + echo " \"include_count\": $INCLUDE_COUNT," + echo " \"exclude_count\": $EXCLUDE_COUNT," + echo " \"renamed_count\": $RENAMED_COUNT," + echo -n " \"include\": [" + for i in "${!INCLUDE_FILES[@]}"; do + [[ $i -gt 0 ]] && echo -n ", " + echo -n "\"${INCLUDE_FILES[$i]}\"" + done + echo "]," + echo -n " \"exclude\": [" + for i in "${!EXCLUDE_FILES[@]}"; do + [[ $i -gt 0 ]] && echo -n ", " + echo -n "\"${EXCLUDE_FILES[$i]}\"" + done + echo "]," + echo -n " \"renamed\": {" + if [[ $RENAMED_COUNT -gt 0 ]]; then + RENAME_IDX=0 + for src in "${!RENAMED_MAP[@]}"; do + [[ $RENAME_IDX -gt 0 ]] && echo -n ", " + echo -n "\"$src\": \"${RENAMED_MAP[$src]}\"" + RENAME_IDX=$((RENAME_IDX + 1)) + done + fi + echo "}" + echo -n " }" + else + # Text output + echo "=== Branch Audit: $branch ===" + echo "" + echo "Include ($INCLUDE_COUNT files):" + for f in "${INCLUDE_FILES[@]}"; do + if [[ -n "${RENAMED_MAP[$f]:-}" ]]; then + echo " $f → ${RENAMED_MAP[$f]} (renamed)" + else + echo " $f" + fi + done + echo "" + if [[ $RENAMED_COUNT -gt 0 ]]; then + echo "Renamed ($RENAMED_COUNT files — fuzzy matched to different filename on branch):" + for src in "${!RENAMED_MAP[@]}"; do + echo " $src → ${RENAMED_MAP[$src]}" + done + echo "" + fi + if [[ $EXCLUDE_COUNT -gt 0 ]]; then + echo "Exclude ($EXCLUDE_COUNT files — not on branch):" + for f in "${EXCLUDE_FILES[@]}"; do + echo " $f" + done + else + echo "Exclude: none (all files exist on this branch)" + fi + echo "" + echo "Summary: $INCLUDE_COUNT/$TOTAL_FILES files applicable to $branch ($RENAMED_COUNT renamed)" + echo "" + fi +done + +if [[ "$JSON_OUTPUT" = true ]]; then + echo "" + echo " }" + echo "}" +fi + +# Step 4: Deep content comparison (if --deep is set) +if [[ "$DEEP" = true && "$DRY_RUN" = false ]]; then + echo "" >&2 + echo "Running deep content comparison..." >&2 + + # Determine source ref (main or PR head) + SOURCE_REF="" + if git rev-parse --verify "remotes/upstream/main" >/dev/null 2>&1; then + SOURCE_REF="remotes/upstream/main" + elif git rev-parse --verify "remotes/origin/main" >/dev/null 2>&1; then + SOURCE_REF="remotes/origin/main" + fi + + if [[ -z "$SOURCE_REF" ]]; then + echo "WARNING: Could not determine source ref for deep audit, skipping" >&2 + else + IFS=',' read -ra BRANCH_ARRAY <<< "$BRANCHES" + for branch in "${BRANCH_ARRAY[@]}"; do + branch=$(echo "$branch" | xargs) + + # Determine target ref + TARGET_REF="" + if git rev-parse --verify "remotes/upstream/$branch" >/dev/null 2>&1; then + TARGET_REF="remotes/upstream/$branch" + elif git rev-parse --verify "remotes/origin/$branch" >/dev/null 2>&1; then + TARGET_REF="remotes/origin/$branch" + fi + + [[ -z "$TARGET_REF" ]] && continue + + # Build included files list for this branch (with fuzzy matching) + INCLUDE_LIST=$(mktemp /tmp/deep-audit-include.XXXXXX) + TARGET_FILE_INDEX_DEEP=$(mktemp /tmp/branch-audit-index-deep.XXXXXX) + git ls-tree -r --name-only "$TARGET_REF" | grep '\.adoc$' > "$TARGET_FILE_INDEX_DEEP" + while IFS= read -r filepath; do + [[ -z "$filepath" ]] && continue + filepath=$(echo "$filepath" | xargs) + if git cat-file -e "${TARGET_REF}:${filepath}" 2>/dev/null; then + echo "$filepath" >> "$INCLUDE_LIST" + else + # Fuzzy match for deep audit + BASENAME_DEEP=$(basename "$filepath") + DIRPATH_DEEP=$(dirname "$filepath") + FUZZY_DEEP=$(python3 -c " +import difflib, sys +basename = sys.argv[1] +candidates = [] +for line in sys.stdin: + line = line.strip() + if not line: + continue + candidates.append(line) +base_map = {} +for c in candidates: + b = c.rsplit('/', 1)[-1] + base_map.setdefault(b, []).append(c) +close = difflib.get_close_matches(basename, list(base_map.keys()), n=1, cutoff=0.85) +if close: + paths = base_map[close[0]] + same_dir = [p for p in paths if p.startswith(sys.argv[2] + '/')] + print(same_dir[0] if same_dir else paths[0]) +" "$BASENAME_DEEP" "$DIRPATH_DEEP" < "$TARGET_FILE_INDEX_DEEP" 2>/dev/null || true) + if [[ -n "$FUZZY_DEEP" ]]; then + # Write as source→target for deep audit to handle path mapping + echo "${filepath}→${FUZZY_DEEP}" >> "$INCLUDE_LIST" + fi + fi + done < "$CANDIDATE_FILES" + rm -f "$TARGET_FILE_INDEX_DEEP" + + echo "" + bash "${SCRIPT_DIR}/deep_audit.sh" \ + --source-ref "$SOURCE_REF" \ + --target-ref "$TARGET_REF" \ + --files "$INCLUDE_LIST" \ + --output-dir "/tmp/deep-audit-${branch}" + + rm -f "$INCLUDE_LIST" + done + fi +fi diff --git a/plugins/docs-tools/skills/docs-branch-audit/scripts/deep_audit.sh b/plugins/docs-tools/skills/docs-branch-audit/scripts/deep_audit.sh new file mode 100755 index 00000000..acc5026b --- /dev/null +++ b/plugins/docs-tools/skills/docs-branch-audit/scripts/deep_audit.sh @@ -0,0 +1,199 @@ +#!/bin/bash +# deep_audit.sh - Deep content comparison for cherry-pick applicability +# +# For each included file, compares the source (main) and target branch versions +# to determine if the PR's changes will apply cleanly and flag version-specific content. +# +# Usage: +# deep_audit.sh --source-ref --target-ref --files [--pr-diff ] +# +# Output: Per-file report with confidence level (high/medium/needs-review) + +set -euo pipefail + +# Defaults +SOURCE_REF="" +TARGET_REF="" +FILE_LIST="" +PR_DIFF="" +OUTPUT_DIR="" + +usage() { + cat <<'USAGE' +Usage: + deep_audit.sh --source-ref --target-ref --files [--pr-diff ] + +Options: + --source-ref Git ref for the source branch (e.g., upstream/main, PR head SHA) + --target-ref Git ref for the target branch (e.g., upstream/enterprise-4.17) + --files Text file with one included file path per line + --pr-diff Optional: unified diff file from the PR for patch dry-run testing + --output-dir Output directory for per-file diffs (default: /tmp/deep-audit) + +USAGE + exit 1 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --source-ref) SOURCE_REF="$2"; shift 2 ;; + --target-ref) TARGET_REF="$2"; shift 2 ;; + --files) FILE_LIST="$2"; shift 2 ;; + --pr-diff) PR_DIFF="$2"; shift 2 ;; + --output-dir) OUTPUT_DIR="$2"; shift 2 ;; + -h|--help) usage ;; + *) echo "ERROR: Unknown option: $1"; usage ;; + esac +done + +# Validate +if [[ -z "$SOURCE_REF" || -z "$TARGET_REF" || -z "$FILE_LIST" ]]; then + echo "ERROR: --source-ref, --target-ref, and --files are all required" + usage +fi + +if [[ ! -f "$FILE_LIST" ]]; then + echo "ERROR: File list not found: $FILE_LIST" >&2 + exit 1 +fi + +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/deep-audit}" +mkdir -p "$OUTPUT_DIR" + +# Counters +COUNT_HIGH=0 +COUNT_MEDIUM=0 +COUNT_REVIEW=0 +TOTAL=0 + +echo "=== Deep Audit: $(basename "$TARGET_REF") ===" +echo "" +echo "Source: $SOURCE_REF" +echo "Target: $TARGET_REF" +echo "" + +while IFS= read -r filepath; do + [[ -z "$filepath" ]] && continue + filepath=$(echo "$filepath" | xargs) + TOTAL=$((TOTAL + 1)) + + BASENAME=$(basename "$filepath" .adoc) + CONFIDENCE="high" + ISSUES=() + + # --- Check 1: Content diff between source and target branch versions --- + # This shows what's different in the file between branches (independent of the PR) + BRANCH_DIFF_FILE="${OUTPUT_DIR}/${BASENAME}.branch-diff" + if git diff "${TARGET_REF}" "${SOURCE_REF}" -- "$filepath" > "$BRANCH_DIFF_FILE" 2>/dev/null; then + BRANCH_DIFF_LINES=$(wc -l < "$BRANCH_DIFF_FILE") + if [[ "$BRANCH_DIFF_LINES" -eq 0 ]]; then + # Files are identical between branches — PR changes will apply cleanly + ISSUES+=("files-identical: File is identical on both branches") + elif [[ "$BRANCH_DIFF_LINES" -gt 100 ]]; then + CONFIDENCE="needs-review" + ISSUES+=("large-divergence: ${BRANCH_DIFF_LINES} lines differ between branches") + elif [[ "$BRANCH_DIFF_LINES" -gt 20 ]]; then + CONFIDENCE="medium" + ISSUES+=("moderate-divergence: ${BRANCH_DIFF_LINES} lines differ between branches") + else + ISSUES+=("minor-divergence: ${BRANCH_DIFF_LINES} lines differ between branches") + fi + else + CONFIDENCE="needs-review" + ISSUES+=("diff-error: Could not diff file between branches") + fi + + # --- Check 2: Version-specific conditional attributes --- + # Look for ifdef/ifndef blocks in the target branch version that gate content by version + TARGET_CONTENT_FILE="${OUTPUT_DIR}/${BASENAME}.target" + git show "${TARGET_REF}:${filepath}" > "$TARGET_CONTENT_FILE" 2>/dev/null || true + if [[ -s "$TARGET_CONTENT_FILE" ]]; then + # Check for version conditionals + VERSION_CONDITIONALS=$(grep -cE 'ifdef::|ifndef::|ifeval::' "$TARGET_CONTENT_FILE" 2>/dev/null || true) + VERSION_CONDITIONALS=${VERSION_CONDITIONALS:-0} + if [[ "$VERSION_CONDITIONALS" -gt 0 ]]; then + if [[ "$CONFIDENCE" != "needs-review" ]]; then + CONFIDENCE="medium" + fi + ISSUES+=("conditionals: ${VERSION_CONDITIONALS} conditional directive(s) found") + fi + + # Check for version-specific attribute references like {product-version} + VERSION_ATTRS=$(grep -cE '\{product-version\}|\{ocp-version\}' "$TARGET_CONTENT_FILE" 2>/dev/null || true) + VERSION_ATTRS=${VERSION_ATTRS:-0} + if [[ "$VERSION_ATTRS" -gt 0 ]]; then + ISSUES+=("version-attrs: ${VERSION_ATTRS} version attribute reference(s)") + fi + fi + + # --- Check 3: Patch applicability (if PR diff provided) --- + if [[ -n "$PR_DIFF" && -f "$PR_DIFF" ]]; then + # Extract just this file's diff from the full PR diff + FILE_PATCH="${OUTPUT_DIR}/${BASENAME}.patch" + # Use awk to extract the diff hunk for this specific file + awk -v file="$filepath" ' + /^diff --git/ { found = ($0 ~ "b/" file "$"); if (found) print; next } + found { print } + /^diff --git/ && !($0 ~ "b/" file "$") { found = 0 } + ' "$PR_DIFF" > "$FILE_PATCH" 2>/dev/null || true + + if [[ -s "$FILE_PATCH" ]]; then + # Try applying the patch in check mode against the target branch version + if git apply --check --3way "$FILE_PATCH" 2>/dev/null; then + ISSUES+=("patch: applies cleanly") + else + PATCH_ERRORS=$(git apply --check --3way "$FILE_PATCH" 2>&1 || true) + if echo "$PATCH_ERRORS" | grep -q "conflict"; then + CONFIDENCE="needs-review" + ISSUES+=("patch-conflict: patch has conflicts") + else + if [[ "$CONFIDENCE" != "needs-review" ]]; then + CONFIDENCE="medium" + fi + ISSUES+=("patch-mismatch: patch context does not match target") + fi + fi + fi + fi + + # --- Check 4: Structural differences --- + # Compare section headings between source and target to detect structural changes + SOURCE_HEADINGS=$(git show "${SOURCE_REF}:${filepath}" 2>/dev/null | grep -cE '^=+ ' || true) + SOURCE_HEADINGS=${SOURCE_HEADINGS:-0} + TARGET_HEADINGS=0 + if [[ -s "$TARGET_CONTENT_FILE" ]]; then + TARGET_HEADINGS=$(grep -cE '^=+ ' "$TARGET_CONTENT_FILE" 2>/dev/null || true) + TARGET_HEADINGS=${TARGET_HEADINGS:-0} + fi + if [[ "$SOURCE_HEADINGS" != "$TARGET_HEADINGS" ]]; then + if [[ "$CONFIDENCE" != "needs-review" ]]; then + CONFIDENCE="medium" + fi + ISSUES+=("structure: heading count differs (source: ${SOURCE_HEADINGS}, target: ${TARGET_HEADINGS})") + fi + + # --- Output --- + case "$CONFIDENCE" in + high) COUNT_HIGH=$((COUNT_HIGH + 1)); SYMBOL="[HIGH]" ;; + medium) COUNT_MEDIUM=$((COUNT_MEDIUM + 1)); SYMBOL="[MEDIUM]" ;; + needs-review) COUNT_REVIEW=$((COUNT_REVIEW + 1)); SYMBOL="[NEEDS-REVIEW]" ;; + esac + + echo "$SYMBOL $filepath" + for issue in "${ISSUES[@]}"; do + echo " $issue" + done + echo "" + +done < "$FILE_LIST" + +# --- Summary --- +echo "=== Deep Audit Summary ===" +echo "" +echo " Total files: $TOTAL" +echo " High: $COUNT_HIGH (changes will apply cleanly)" +echo " Medium: $COUNT_MEDIUM (likely applies, review recommended)" +echo " Needs review: $COUNT_REVIEW (conflicts or large divergence expected)" +echo "" +echo "Per-file diffs saved to: $OUTPUT_DIR" diff --git a/plugins/docs-tools/skills/docs-cherry-pick/SKILL.md b/plugins/docs-tools/skills/docs-cherry-pick/SKILL.md new file mode 100644 index 00000000..ffb76b30 --- /dev/null +++ b/plugins/docs-tools/skills/docs-cherry-pick/SKILL.md @@ -0,0 +1,123 @@ +--- +name: docs-cherry-pick +description: Intelligently cherry-pick documentation changes to enterprise branches, excluding files that don't exist on each target release +argument-hint: --target [--dry-run] +allowed-tools: Read, Write, Glob, Grep, Edit, Bash, AskUserQuestion, Agent +disable-model-invocation: true +--- + +# Cherry-Pick Backport + +Backport documentation changes from a PR or commit to enterprise branches. Automatically excludes files not present on target releases and resolves cherry-pick conflicts. + +**Required:** source (PR URL or `--commit `) and `--target `. If either is missing, ask the user. + +## Usage + +```bash +# Run the cherry-pick script +bash ${CLAUDE_SKILL_DIR}/scripts/cherry_pick.sh \ + --pr --target [--dry-run] [--deep] [--no-push] [--ticket ] + +# Commit mode +bash ${CLAUDE_SKILL_DIR}/scripts/cherry_pick.sh \ + --commit --target +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--target ` | Comma-separated target branches (required) | +| `--commit ` | Use a commit SHA instead of PR URL | +| `--dry-run` | Audit only — show what would be included/excluded | +| `--deep` | Deep content comparison for patch applicability | +| `--no-push` | Create branch locally, don't push | +| `--ticket ` | JIRA ticket ID (auto-detected from PR title) | + +## Workflow + +1. Run the script. For `--dry-run`, display results and stop. +2. For full runs, the script handles: validate, audit, cherry-pick, commit, push. +3. If the script exits with code **2**, there are conflicts. Follow the conflict resolution process below. + +## Conflict Resolution (exit code 2) + +When the script reports conflicts, resolve them using these steps: + +### Step 1: Read conflict state + +```bash +cat /tmp/cherry-pick-state/conflicted-files.txt +cat /tmp/cherry-pick-state/current-target.txt +``` + +### Step 2: Resolve each conflicted file + +Spawn one Agent per conflicted file for parallel resolution: + +``` +Agent(subagent_type="general-purpose", prompt=" + Resolve cherry-pick conflict in . + Target branch: + The PR applied editorial/DITA-prep changes on main. + + 1. Read the file (contains <<<<<<< / ======= / >>>>>>> markers) + 2. Apply resolution rules (see below) + 3. Remove ALL conflict markers + 4. Verify valid AsciiDoc + 5. Return summary: FILE, CONFLICTS count, RESOLVED count, DETAILS +") +``` + +### Resolution rules + +| Conflict type | Resolution | +|---------------|------------| +| Editorial fix (abstract tag, callout, block delimiter) | Apply fix to target branch content | +| Content exists on both branches, differs slightly | Apply editorial fix, keep target wording | +| Content only exists on main (new feature) | Keep target branch version | +| UI element references (e.g., Operators vs Ecosystem) | Keep target version (UI may differ) | +| Xref paths differ between branches | Keep target branch xref paths | +| New xref to anchor/module not on target branch | Drop the xref | +| Ambiguous | Keep target version, flag with `// REVIEW: ` | + +### Step 3: After resolution + +```bash +# Verify no conflict markers remain +grep -rn '<<<<<<\|======\|>>>>>>' + +# Stage and commit +git add +TICKET=$(cat /tmp/cherry-pick-state/ticket.txt) +TARGET=$(cat /tmp/cherry-pick-state/current-target.txt) +git commit -m "${TICKET}: Backport to ${TARGET} + +Co-Authored-By: Claude " +``` + +### Step 4: Resume push phase + +```bash +bash ${CLAUDE_SKILL_DIR}/scripts/cherry_pick.sh \ + --pr --target --phase push +``` + +### Step 5: Handle `// REVIEW:` flags + +If any conflicts were flagged, present them to the user via AskUserQuestion with the two versions and ask which to keep. + +## Path differences + +Assemblies may have different paths across releases (e.g., `networking/ptp/` on 4.16 became `networking/advanced_networking/ptp/` on 4.17+). The script detects these automatically. + +When a path difference causes a modify/delete conflict: +1. Remove the conflict file at the old path: `git rm ` +2. Apply the PR's editorial changes to the file at the target branch path +3. Keep xref paths correct for the target branch's directory structure + +## Related + +- `docs-tools:docs-branch-audit` — file existence and content comparison +- `docs-tools:git-pr-reader` — PR/MR file listing and diff extraction diff --git a/plugins/docs-tools/skills/docs-cherry-pick/scripts/cherry_pick.sh b/plugins/docs-tools/skills/docs-cherry-pick/scripts/cherry_pick.sh new file mode 100755 index 00000000..2eab5b6d --- /dev/null +++ b/plugins/docs-tools/skills/docs-cherry-pick/scripts/cherry_pick.sh @@ -0,0 +1,637 @@ +#!/bin/bash +# cherry_pick.sh - Deterministic cherry-pick backport operations +# +# Handles: PR info fetching, branch audit, cherry-pick execution, push, PR description. +# Conflict RESOLUTION is delegated back to Claude (the caller) — this script only +# identifies conflicts and reports them. +# +# Usage: +# cherry_pick.sh --pr --target [options] +# cherry_pick.sh --commit --target [options] +# +# Phases: +# validate - Check inputs, fetch PR info, resolve commit SHA +# audit - Run branch audit, detect path differences +# apply - Create branch, cherry-pick, report conflicts +# push - Push branch, generate PR description +# +# Exit codes: +# 0 - Success (or dry-run completed) +# 1 - Error (missing inputs, bad state) +# 2 - Cherry-pick has conflicts (caller should resolve, then re-run with --phase push) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(dirname "$SCRIPT_DIR")" +PLUGIN_DIR="$(dirname "$(dirname "$SKILL_DIR")")" +BRANCH_AUDIT="${PLUGIN_DIR}/skills/docs-branch-audit/scripts/branch_audit.sh" +GIT_PR_READER="${PLUGIN_DIR}/skills/git-pr-reader/scripts/git_pr_reader.py" + +# State directory for intermediate files +STATE_DIR="/tmp/cherry-pick-state" + +# Defaults +PR_URL="" +COMMIT_SHA="" +TARGET_BRANCHES="" +DRY_RUN=false +DEEP=false +NO_PUSH=false +TICKET="" +PHASE="" # empty = full run, or: validate, audit, apply, push + +usage() { + cat <<'USAGE' +Usage: + cherry_pick.sh --pr --target [options] + cherry_pick.sh --commit --target [options] + +Options: + --pr GitHub PR or GitLab MR URL + --commit Git commit SHA + --target Comma-separated target branches (required) + --dry-run Audit only, no changes + --deep Deep content comparison + --no-push Don't push, just create local branch + --ticket JIRA ticket ID (auto-detected from PR title if omitted) + --phase Run a single phase: validate, audit, apply, push + +Output: + Writes structured JSON/text to /tmp/cherry-pick-state/ for each phase. + The caller reads these files to drive the workflow. +USAGE + exit 1 +} + +# --- Argument parsing --- + +while [[ $# -gt 0 ]]; do + case "$1" in + --pr) PR_URL="$2"; shift 2 ;; + --commit) COMMIT_SHA="$2"; shift 2 ;; + --target) TARGET_BRANCHES="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --deep) DEEP=true; shift ;; + --no-push) NO_PUSH=true; shift ;; + --ticket) TICKET="$2"; shift 2 ;; + --phase) PHASE="$2"; shift 2 ;; + -h|--help) usage ;; + *) + # Treat bare URL-like args as PR URL + if [[ "$1" == http* && -z "$PR_URL" ]]; then + PR_URL="$1"; shift + else + echo "ERROR: Unknown option: $1" >&2; usage + fi + ;; + esac +done + +if [[ -z "$TARGET_BRANCHES" ]]; then + echo "ERROR: --target is required" >&2 + usage +fi + +if [[ -z "$PR_URL" && -z "$COMMIT_SHA" ]]; then + echo "ERROR: --pr or --commit is required" >&2 + usage +fi + +# Ensure state directory exists +mkdir -p "$STATE_DIR" + +# --- Helper functions --- + +detect_platform() { + if echo "$PR_URL" | grep -q "github.com"; then + echo "github" + elif echo "$PR_URL" | grep -q "gitlab"; then + echo "gitlab" + else + echo "unknown" + fi +} + +fetch_pr_info_gh() { + local pr_url="$1" + local pr_number owner_repo + + pr_number=$(echo "$pr_url" | grep -oP '/pull/\K[0-9]+' || echo "") + owner_repo=$(echo "$pr_url" | grep -oP 'github\.com/\K[^/]+/[^/]+' || echo "") + + if [[ -z "$pr_number" || -z "$owner_repo" ]]; then + echo "ERROR: Could not parse PR URL: $pr_url" >&2 + return 1 + fi + + # Try gh CLI (unset tokens to use keyring auth) + if command -v gh >/dev/null 2>&1; then + GITHUB_TOKEN= GH_TOKEN= gh api "repos/${owner_repo}/pulls/${pr_number}" 2>/dev/null && return 0 + fi + + # Fallback to git_pr_reader.py + if [[ -f "$GIT_PR_READER" ]]; then + python3 "$GIT_PR_READER" info "$pr_url" --json 2>/dev/null && return 0 + fi + + echo "ERROR: Could not fetch PR info. Check gh auth or GITHUB_TOKEN." >&2 + return 1 +} + +fetch_pr_files_gh() { + local pr_url="$1" + local pr_number owner_repo + + pr_number=$(echo "$pr_url" | grep -oP '/pull/\K[0-9]+' || echo "") + owner_repo=$(echo "$pr_url" | grep -oP 'github\.com/\K[^/]+/[^/]+' || echo "") + + # Try gh CLI first + if command -v gh >/dev/null 2>&1; then + if GITHUB_TOKEN= GH_TOKEN= gh api "repos/${owner_repo}/pulls/${pr_number}/files" --paginate --jq '.[].filename' 2>/dev/null; then + return 0 + fi + fi + + # Fallback to git_pr_reader.py + if [[ -f "$GIT_PR_READER" ]]; then + if python3 "$GIT_PR_READER" files "$pr_url" 2>/dev/null; then + return 0 + fi + fi + + echo "ERROR: Could not fetch PR files." >&2 + return 1 +} + +# --- Phase: Validate --- + +phase_validate() { + echo "=== Phase: Validate ===" >&2 + + git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "ERROR: Not in a git repo" >&2; exit 1 + } + + if [[ -n "$PR_URL" ]]; then + local platform + platform=$(detect_platform) + echo "Platform: $platform" >&2 + echo "PR URL: $PR_URL" >&2 + + # Fetch PR info + echo "Fetching PR info..." >&2 + fetch_pr_info_gh "$PR_URL" > "$STATE_DIR/pr-info.json" + + # Extract ticket from title if not provided + if [[ -z "$TICKET" ]]; then + TICKET=$(python3 -c "import json,re,sys; d=json.load(open('$STATE_DIR/pr-info.json')); m=re.match(r'^([A-Z]+-[0-9]+)', d.get('title','')); print(m.group(1) if m else '')" 2>/dev/null || echo "") + fi + + # Determine PR state and commit SHA + local pr_state pr_merged merge_sha head_sha + pr_state=$(python3 -c "import json; print(json.load(open('$STATE_DIR/pr-info.json')).get('state',''))" 2>/dev/null || echo "") + pr_merged=$(python3 -c "import json; d=json.load(open('$STATE_DIR/pr-info.json')); print('true' if d.get('merged') or d.get('merged_at') or d.get('mergedAt') else 'false')" 2>/dev/null || echo "false") + merge_sha=$(python3 -c "import json; d=json.load(open('$STATE_DIR/pr-info.json')); print(d.get('merge_commit_sha') or d.get('mergeCommit',{}).get('oid','') if isinstance(d.get('mergeCommit'),dict) else d.get('merge_commit_sha',''))" 2>/dev/null || echo "") + head_sha=$(python3 -c "import json; d=json.load(open('$STATE_DIR/pr-info.json')); h=d.get('head',{}); print(h.get('sha','') or h.get('oid',''))" 2>/dev/null || echo "") + + if [[ "$pr_merged" == "true" ]]; then + COMMIT_SHA="$merge_sha" + echo "PR merged. Using merge commit: $COMMIT_SHA" >&2 + elif [[ "$pr_state" == "open" || "$pr_state" == "OPEN" ]]; then + local pr_number + pr_number=$(echo "$PR_URL" | grep -oP '\d+$') + if [[ "$platform" == "github" ]]; then + echo "PR is open. Fetching PR head ref..." >&2 + git fetch upstream "pull/${pr_number}/head:pr-${pr_number}" 2>&1 >&2 || true + COMMIT_SHA=$(git rev-parse "pr-${pr_number}" 2>/dev/null || echo "$head_sha") + elif [[ "$platform" == "gitlab" ]]; then + git fetch upstream "merge-requests/${pr_number}/head:mr-${pr_number}" 2>&1 >&2 || true + COMMIT_SHA=$(git rev-parse "mr-${pr_number}" 2>/dev/null || echo "$head_sha") + fi + echo "WARNING: PR is not merged. Using head commit: $COMMIT_SHA" >&2 + else + echo "ERROR: PR is closed without being merged. Cannot cherry-pick." >&2 + exit 1 + fi + + # Fetch file list + echo "Fetching changed files..." >&2 + fetch_pr_files_gh "$PR_URL" > "$STATE_DIR/source-files.txt" + + else + # Commit mode + if ! git cat-file -e "$COMMIT_SHA" 2>/dev/null; then + echo "ERROR: Commit $COMMIT_SHA not found. Try 'git fetch upstream' first." >&2 + exit 1 + fi + git diff-tree --no-commit-id --name-only -r "$COMMIT_SHA" > "$STATE_DIR/source-files.txt" + fi + + # Save state + echo "$COMMIT_SHA" > "$STATE_DIR/commit-sha.txt" + echo "$TICKET" > "$STATE_DIR/ticket.txt" + echo "$TARGET_BRANCHES" > "$STATE_DIR/target-branches.txt" + + local file_count + file_count=$(wc -l < "$STATE_DIR/source-files.txt") + echo "Found $file_count changed file(s)" >&2 + echo "Ticket: ${TICKET:-}" >&2 + echo "Commit SHA: $COMMIT_SHA" >&2 + + # Write validate summary as JSON + python3 -c " +import json +info = {} +try: + info = json.load(open('$STATE_DIR/pr-info.json')) +except: pass +summary = { + 'commit_sha': '$COMMIT_SHA', + 'ticket': '$TICKET', + 'pr_title': info.get('title', ''), + 'pr_state': info.get('state', ''), + 'file_count': $file_count, + 'target_branches': '$TARGET_BRANCHES'.split(','), + 'source': '${PR_URL:-commit:$COMMIT_SHA}' +} +json.dump(summary, open('$STATE_DIR/validate-summary.json', 'w'), indent=2) +print(json.dumps(summary, indent=2)) +" +} + +# --- Phase: Audit --- + +phase_audit() { + echo "=== Phase: Audit ===" >&2 + + local commit_sha target_branches + commit_sha=$(cat "$STATE_DIR/commit-sha.txt" 2>/dev/null || echo "$COMMIT_SHA") + target_branches=$(cat "$STATE_DIR/target-branches.txt" 2>/dev/null || echo "$TARGET_BRANCHES") + + # Determine audit source flag + local audit_source_flag="" + if [[ -n "$PR_URL" ]]; then + audit_source_flag="--pr $PR_URL" + elif [[ -n "$commit_sha" ]]; then + audit_source_flag="--commit $commit_sha" + else + audit_source_flag="--files $STATE_DIR/source-files.txt" + fi + + # Run branch audit (text output) + echo "" >&2 + bash "$BRANCH_AUDIT" $audit_source_flag --branches "$target_branches" \ + 2>&1 | tee "$STATE_DIR/audit-text.txt" + + # Run branch audit (JSON output) + bash "$BRANCH_AUDIT" $audit_source_flag --branches "$target_branches" \ + --json > "$STATE_DIR/audit.json" 2>/dev/null || true + + # Detect assembly path differences + echo "" >&2 + echo "=== Assembly Path Analysis ===" >&2 + + local path_diffs_found=false + IFS=',' read -ra BRANCH_ARRAY <<< "$target_branches" + > "$STATE_DIR/path-diffs.txt" # clear + + for branch in "${BRANCH_ARRAY[@]}"; do + branch=$(echo "$branch" | xargs) + local ref="" + if git rev-parse --verify "remotes/upstream/$branch" >/dev/null 2>&1; then + ref="remotes/upstream/$branch" + elif git rev-parse --verify "remotes/origin/$branch" >/dev/null 2>&1; then + ref="remotes/origin/$branch" + else + continue + fi + + while IFS= read -r filepath; do + [[ -z "$filepath" ]] && continue + filepath=$(echo "$filepath" | xargs) + # Skip modules (they don't move) and non-assembly files + [[ "$filepath" == modules/* ]] && continue + + if ! git cat-file -e "${ref}:${filepath}" 2>/dev/null; then + local basename alt_path + basename=$(basename "$filepath") + alt_path=$(git ls-tree -r --name-only "$ref" | grep "/${basename}$" | head -1 || echo "") + if [[ -n "$alt_path" ]]; then + echo "PATH DIFFERENCE: $filepath -> $alt_path on $branch" | tee -a "$STATE_DIR/path-diffs.txt" + path_diffs_found=true + fi + fi + done < "$STATE_DIR/source-files.txt" + done + + if [[ "$path_diffs_found" = false ]]; then + echo "No path differences detected" >&2 + fi + + # Deep audit if requested + if [[ "$DEEP" = true ]]; then + echo "" >&2 + echo "Running deep content comparison..." >&2 + if [[ -n "$PR_URL" ]]; then + bash "$BRANCH_AUDIT" --pr "$PR_URL" --branches "$target_branches" --deep \ + 2>&1 | tee "$STATE_DIR/deep-audit.txt" + else + bash "$BRANCH_AUDIT" --commit "$commit_sha" --branches "$target_branches" --deep \ + 2>&1 | tee "$STATE_DIR/deep-audit.txt" + fi + fi + + # If dry-run, print summary and exit + if [[ "$DRY_RUN" = true ]]; then + echo "" >&2 + echo "=== DRY RUN COMPLETE ===" >&2 + echo "No changes made. Review the audit output above." >&2 + exit 0 + fi +} + +# --- Phase: Apply --- + +phase_apply() { + local target_branch="$1" + echo "=== Phase: Apply ($target_branch) ===" >&2 + + local commit_sha ticket + commit_sha=$(cat "$STATE_DIR/commit-sha.txt") + ticket=$(cat "$STATE_DIR/ticket.txt" 2>/dev/null || echo "") + + local branch_name="${ticket:-cherry-pick}-${target_branch##*-}-CP" + + # Fetch target branch + git fetch upstream "$target_branch" 2>&1 >&2 + + # Save original branch to return to later + git rev-parse --abbrev-ref HEAD > "$STATE_DIR/original-branch.txt" 2>/dev/null || echo "main" > "$STATE_DIR/original-branch.txt" + + # Create backport branch + git checkout -b "$branch_name" "upstream/$target_branch" 2>&1 >&2 + echo "Created branch: $branch_name from upstream/$target_branch" >&2 + + # Get excluded files from audit JSON + local exclude_files=() + if [[ -f "$STATE_DIR/audit.json" ]]; then + mapfile -t exclude_files < <(python3 -c " +import json +d = json.load(open('$STATE_DIR/audit.json')) +branch_data = d.get('branches', {}).get('$target_branch', {}) +for f in branch_data.get('exclude', []): + print(f) +" 2>/dev/null || true) + fi + + # Attempt cherry-pick + echo "Cherry-picking commit $commit_sha..." >&2 + local cp_exit=0 + git cherry-pick --no-commit "$commit_sha" 2>"$STATE_DIR/cherry-pick-stderr.txt" || cp_exit=$? + + # Remove excluded files regardless of cherry-pick result + for excluded_file in "${exclude_files[@]}"; do + git checkout HEAD -- "$excluded_file" 2>/dev/null || git rm --cached "$excluded_file" 2>/dev/null || true + done + + # Save branch info + echo "$branch_name" > "$STATE_DIR/branch-name.txt" + echo "$target_branch" > "$STATE_DIR/current-target.txt" + echo "${#exclude_files[@]}" > "$STATE_DIR/exclude-count.txt" + printf '%s\n' "${exclude_files[@]}" > "$STATE_DIR/exclude-files.txt" 2>/dev/null || true + + if [[ $cp_exit -eq 0 ]]; then + echo "Cherry-pick applied cleanly" >&2 + echo "clean" > "$STATE_DIR/cherry-pick-status.txt" + echo "" > "$STATE_DIR/conflicted-files.txt" + + # Get included file count + local include_count + if [[ -f "$STATE_DIR/audit.json" ]]; then + include_count=$(python3 -c " +import json +d = json.load(open('$STATE_DIR/audit.json')) +print(d.get('branches', {}).get('$target_branch', {}).get('include_count', 0)) +" 2>/dev/null || echo "0") + else + include_count=$(wc -l < "$STATE_DIR/source-files.txt") + fi + + # Auto-commit for clean cherry-picks + local source + source=$(cat "$STATE_DIR/validate-summary.json" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('source',''))" 2>/dev/null || echo "") + + git add -A + git commit -m "$(cat < +EOF +)" 2>&1 >&2 + + echo "Committed changes" >&2 + else + echo "conflicts" > "$STATE_DIR/cherry-pick-status.txt" + + # List conflicted files + git diff --name-only --diff-filter=U > "$STATE_DIR/conflicted-files.txt" 2>/dev/null || true + + local conflict_count + conflict_count=$(wc -l < "$STATE_DIR/conflicted-files.txt") + echo "Cherry-pick has $conflict_count conflicted file(s):" >&2 + cat "$STATE_DIR/conflicted-files.txt" >&2 + echo "" >&2 + echo "ACTION REQUIRED: Resolve conflicts, then re-run with --phase push" >&2 + exit 2 + fi +} + +# --- Phase: Push --- + +phase_push() { + echo "=== Phase: Push ===" >&2 + + local branch_name target_branch ticket source + branch_name=$(cat "$STATE_DIR/branch-name.txt") + target_branch=$(cat "$STATE_DIR/current-target.txt") + ticket=$(cat "$STATE_DIR/ticket.txt" 2>/dev/null || echo "") + source=$(python3 -c "import json,sys; print(json.load(open('$STATE_DIR/validate-summary.json')).get('source',''))" 2>/dev/null || echo "") + + local exclude_count include_count source_file_count + exclude_count=$(cat "$STATE_DIR/exclude-count.txt" 2>/dev/null || echo "0") + + if [[ -f "$STATE_DIR/audit.json" ]]; then + include_count=$(python3 -c " +import json +d = json.load(open('$STATE_DIR/audit.json')) +print(d.get('branches', {}).get('$target_branch', {}).get('include_count', 0)) +" 2>/dev/null || echo "0") + else + include_count=$(wc -l < "$STATE_DIR/source-files.txt") + fi + source_file_count=$(wc -l < "$STATE_DIR/source-files.txt") + + # Diff stats comparison + local backport_stats + backport_stats=$(git diff --stat "upstream/${target_branch}...HEAD" 2>/dev/null || echo "") + local backport_file_count backport_insertions backport_deletions + backport_file_count=$(git diff --numstat "upstream/${target_branch}...HEAD" 2>/dev/null | wc -l || echo "0") + backport_insertions=$(git diff --numstat "upstream/${target_branch}...HEAD" 2>/dev/null | awk '{s+=$1} END {print s+0}' || echo "0") + backport_deletions=$(git diff --numstat "upstream/${target_branch}...HEAD" 2>/dev/null | awk '{s+=$2} END {print s+0}' || echo "0") + + # Read path diffs + local path_notes="" + if [[ -s "$STATE_DIR/path-diffs.txt" ]]; then + path_notes=$(cat "$STATE_DIR/path-diffs.txt") + fi + + # Read exclude files + local exclude_list="" + if [[ -s "$STATE_DIR/exclude-files.txt" ]]; then + exclude_list=$(while IFS= read -r f; do + [[ -z "$f" ]] && continue + echo "| \`$(basename "$f" .adoc)\` | Not present on $target_branch |" + done < "$STATE_DIR/exclude-files.txt") + fi + + # Read included files from audit + local include_list="" + if [[ -f "$STATE_DIR/audit.json" ]]; then + include_list=$(python3 -c " +import json +d = json.load(open('$STATE_DIR/audit.json')) +for f in d.get('branches', {}).get('$target_branch', {}).get('include', []): + print(f'- \`{f}\`') +" 2>/dev/null || echo "") + fi + + # PR title from source + local pr_title="" + if [[ -f "$STATE_DIR/pr-info.json" ]]; then + pr_title=$(python3 -c "import json; print(json.load(open('$STATE_DIR/pr-info.json')).get('title',''))" 2>/dev/null || echo "") + fi + + # Generate PR description + cat > "$STATE_DIR/pr-description.md" <&2 + git push -u origin "$branch_name" 2>&1 + echo "" >&2 + echo "Branch pushed: $branch_name" >&2 + else + echo "Branch created locally: $branch_name (--no-push)" >&2 + fi + + echo "PR description written to: $STATE_DIR/pr-description.md" >&2 + echo "" >&2 + + # Print summary + cat <&2; exit 1 ;; + esac +} + +if [[ -n "$PHASE" ]]; then + # Run single phase + run_phase "$PHASE" +else + # Full run: validate -> audit -> apply -> push + phase_validate + phase_audit + + if [[ "$DRY_RUN" = true ]]; then + exit 0 + fi + + # Process each target branch + IFS=',' read -ra BRANCH_ARRAY <<< "$TARGET_BRANCHES" + for branch in "${BRANCH_ARRAY[@]}"; do + branch=$(echo "$branch" | xargs) + phase_apply "$branch" + phase_push + + # Return to original branch before processing next + if [[ ${#BRANCH_ARRAY[@]} -gt 1 ]]; then + orig_branch=$(cat "$STATE_DIR/original-branch.txt" 2>/dev/null || echo "main") + git checkout "$orig_branch" 2>&1 >&2 || true + fi + done +fi From 209eceacd657d515d6aad2c7877d124dcdaddf2a Mon Sep 17 00:00:00 2001 From: Kevin Quinn Date: Tue, 7 Apr 2026 09:51:25 +0100 Subject: [PATCH 2/3] chore: bump docs-tools plugin version to 0.0.22 Co-Authored-By: Claude Opus 4.6 --- plugins/docs-tools/.claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/docs-tools/.claude-plugin/plugin.json b/plugins/docs-tools/.claude-plugin/plugin.json index bbbb6b8f..f0044734 100644 --- a/plugins/docs-tools/.claude-plugin/plugin.json +++ b/plugins/docs-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "docs-tools", - "version": "0.0.21", + "version": "0.0.22", "description": "Documentation review, writing, and workflow tools for Red Hat AsciiDoc and Markdown documentation.", "author": { "name": "Red Hat Documentation Team", From bfc53fb3a6860efa105978fe5f741631ce4f8145 Mon Sep 17 00:00:00 2001 From: Kevin Quinn Date: Wed, 22 Apr 2026 10:53:28 +0100 Subject: [PATCH 3/3] updating skill based on check --- .../skills/docs-cherry-pick/SKILL.md | 75 ++++++++----------- 1 file changed, 30 insertions(+), 45 deletions(-) diff --git a/plugins/docs-tools/skills/docs-cherry-pick/SKILL.md b/plugins/docs-tools/skills/docs-cherry-pick/SKILL.md index ffb76b30..315da055 100644 --- a/plugins/docs-tools/skills/docs-cherry-pick/SKILL.md +++ b/plugins/docs-tools/skills/docs-cherry-pick/SKILL.md @@ -37,85 +37,70 @@ bash ${CLAUDE_SKILL_DIR}/scripts/cherry_pick.sh \ ## Workflow -1. Run the script. For `--dry-run`, display results and stop. -2. For full runs, the script handles: validate, audit, cherry-pick, commit, push. -3. If the script exits with code **2**, there are conflicts. Follow the conflict resolution process below. +The script handles automation; the agent only intervenes for conflicts (exit code 2). -## Conflict Resolution (exit code 2) +| Exit code | Action | +|-----------|--------| +| **0** | Success — nothing to do | +| **1** | Fatal error — show stderr to user, stop | +| **2** | Conflicts — resolve using steps below | -When the script reports conflicts, resolve them using these steps: +For `--dry-run`, display results and stop. -### Step 1: Read conflict state +## Conflict Resolution (exit code 2) -```bash -cat /tmp/cherry-pick-state/conflicted-files.txt -cat /tmp/cherry-pick-state/current-target.txt -``` +State files are in `/tmp/cherry-pick-state/` (`conflicted-files.txt`, `current-target.txt`, `ticket.txt`). -### Step 2: Resolve each conflicted file +### Step 1: Resolve conflicted files -Spawn one Agent per conflicted file for parallel resolution: +Read `conflicted-files.txt` and `current-target.txt`. Spawn one Agent per file for parallel resolution: ``` Agent(subagent_type="general-purpose", prompt=" - Resolve cherry-pick conflict in . - Target branch: - The PR applied editorial/DITA-prep changes on main. - - 1. Read the file (contains <<<<<<< / ======= / >>>>>>> markers) - 2. Apply resolution rules (see below) - 3. Remove ALL conflict markers - 4. Verify valid AsciiDoc - 5. Return summary: FILE, CONFLICTS count, RESOLVED count, DETAILS + Resolve cherry-pick conflict in . Target: . + 1. Read file, apply resolution rules, remove ALL conflict markers + 2. Verify valid AsciiDoc + 3. Return: FILE, CONFLICTS count, RESOLVED count, DETAILS ") ``` +After all agents return, verify no unresolved markers or `// REVIEW:` flags remain before continuing. + ### Resolution rules +The guiding principle: apply editorial improvements, but keep target-branch content and paths when they differ substantively. + | Conflict type | Resolution | |---------------|------------| -| Editorial fix (abstract tag, callout, block delimiter) | Apply fix to target branch content | -| Content exists on both branches, differs slightly | Apply editorial fix, keep target wording | -| Content only exists on main (new feature) | Keep target branch version | -| UI element references (e.g., Operators vs Ecosystem) | Keep target version (UI may differ) | -| Xref paths differ between branches | Keep target branch xref paths | -| New xref to anchor/module not on target branch | Drop the xref | -| Ambiguous | Keep target version, flag with `// REVIEW: ` | +| Editorial fix (abstract tag, callout, block delimiter) | Apply the fix to target content | +| Both branches have content, minor differences | Apply editorial fix, keep target wording and feature names | +| Content only on main (new feature, new `include::`) | Keep target version, drop new content | +| UI element names differ across releases | Keep target version (e.g., "Operators" not "Ecosystem Catalog") | +| Xref paths differ / new xref to missing module | Keep target paths; drop xrefs to modules not on target | +| Ambiguous (both sides changed same block substantively) | Keep target, flag with `// REVIEW: ` | -### Step 3: After resolution +### Step 2: Stage, commit, and push ```bash -# Verify no conflict markers remain -grep -rn '<<<<<<\|======\|>>>>>>' - -# Stage and commit +grep -rn '<<<<<<\|======\|>>>>>>' # verify clean git add TICKET=$(cat /tmp/cherry-pick-state/ticket.txt) TARGET=$(cat /tmp/cherry-pick-state/current-target.txt) git commit -m "${TICKET}: Backport to ${TARGET} Co-Authored-By: Claude " -``` - -### Step 4: Resume push phase -```bash bash ${CLAUDE_SKILL_DIR}/scripts/cherry_pick.sh \ --pr --target --phase push ``` -### Step 5: Handle `// REVIEW:` flags +### Step 3: Handle `// REVIEW:` flags -If any conflicts were flagged, present them to the user via AskUserQuestion with the two versions and ask which to keep. +If any flags remain, present both versions to the user via AskUserQuestion and ask which to keep. ## Path differences -Assemblies may have different paths across releases (e.g., `networking/ptp/` on 4.16 became `networking/advanced_networking/ptp/` on 4.17+). The script detects these automatically. - -When a path difference causes a modify/delete conflict: -1. Remove the conflict file at the old path: `git rm ` -2. Apply the PR's editorial changes to the file at the target branch path -3. Keep xref paths correct for the target branch's directory structure +The script detects path changes across releases automatically. For modify/delete conflicts caused by path moves: `git rm `, apply edits at the target path, keep target xref paths. ## Related