From e431abf8ef46584edbbda9cb6aecccdd7c7f5de3 Mon Sep 17 00:00:00 2001 From: wauxing Date: Tue, 7 Jul 2026 19:24:23 +0800 Subject: [PATCH] chore(sweep): add workflow runtime deps + test repo scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflows/community-ops-sweep/scripts/gitlink_workflow.py: CLI wrapper imported by community_ops_sweep.py (was missing from tree → import error) - community-ops-sweep.wf.js: fix ENGINE path (examples/ → workflows/) and pass --routing to the plan phase - scripts/restore-test-repo.sh: reset baoerjun/gitlink-cli test repo; fix issue batch-delete flag (--yes → --confirm, the CLI uses --confirm and silently skipped deletion on unknown flag, causing duplicate imports) - scripts/import-test-data.sh: companion importer called by restore --- scripts/import-test-data.sh | 521 +++++++++++ scripts/restore-test-repo.sh | 461 ++++++++++ .../community-ops-sweep.wf.js | 4 +- .../scripts/gitlink_workflow.py | 814 ++++++++++++++++++ 4 files changed, 1798 insertions(+), 2 deletions(-) create mode 100755 scripts/import-test-data.sh create mode 100644 scripts/restore-test-repo.sh create mode 100644 workflows/community-ops-sweep/scripts/gitlink_workflow.py diff --git a/scripts/import-test-data.sh b/scripts/import-test-data.sh new file mode 100755 index 0000000..4812190 --- /dev/null +++ b/scripts/import-test-data.sh @@ -0,0 +1,521 @@ +#!/usr/bin/env bash +# ============================================================================ +# import-test-data.sh +# Import 15 issues + 6 PRs from upstream (gitlink/gitlink-cli) into the +# test repo (baoerjun/gitlink-cli) for repeatable testing. +# +# Usage: +# ./scripts/import-test-data.sh [ISSUE_COUNT] [PR_COUNT] +# +# ISSUE_COUNT Number of issues to import (default: 15) +# PR_COUNT Number of PRs to import (default: 6) +# +# --yes Skip confirmation prompts +# ============================================================================ +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +DATA_DIR="$PROJECT_DIR/data" +CLI="${PROJECT_DIR}/gitlink-cli" +MANIFEST="$DATA_DIR/test-data-manifest.json" + +UPSTREAM_OWNER="gitlink" +TEST_OWNER="baoerjun" +REPO="gitlink-cli" +FORK_REMOTE="fork" + +ISSUE_COUNT="${1:-15}" +PR_COUNT="${2:-6}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $*"; } + +# --------------------------------------------------------------------------- +# Check prerequisites +# --------------------------------------------------------------------------- +check_prereqs() { + if [ ! -f "$CLI" ]; then + log_error "gitlink-cli binary not found at $CLI" + exit 1 + fi + if ! git remote get-url "$FORK_REMOTE" &>/dev/null; then + log_error "Git remote '$FORK_REMOTE' not found. Required for pushing PR branches." + exit 1 + fi + mkdir -p "$DATA_DIR" +} + +# --------------------------------------------------------------------------- +# Idempotency guard: check for existing manifest +# --------------------------------------------------------------------------- +check_existing_manifest() { + if [ -f "$MANIFEST" ]; then + log_warn "Existing manifest found at $MANIFEST" + log_warn "Test data may already exist in the test repo." + echo "" + read -r -p "Run restore first, then re-import? [y/N] " answer + if [[ "$answer" =~ ^[Yy]$ ]]; then + log_info "Please run: scripts/restore-test-repo.sh first, then re-run this script." + exit 0 + fi + log_info "Continuing will overwrite the existing manifest..." + fi + + # Also warn if test repo already has issues beyond baseline + local existing + existing=$("$CLI" --owner "$TEST_OWNER" --repo "$REPO" \ + issue +list --format json --limit 1 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data'].get('total_count',0))" 2>/dev/null || echo "0") + if [ "$existing" -gt 0 ] 2>/dev/null; then + log_warn "Test repo already has $existing issue(s). The restore script will delete ALL of them." + fi +} + +# --------------------------------------------------------------------------- +# Git state save/restore +# --------------------------------------------------------------------------- +save_git_state() { + ORIG_BRANCH="$(git branch --show-current)" + if ! git diff-index --quiet HEAD -- 2>/dev/null; then + log_info "Stashing uncommitted changes before git operations..." + git stash push -m "import-test-data: auto-stash $(date +%s)" + STASHED=1 + else + STASHED=0 + fi +} + +restore_git_state() { + log_info "Restoring git state..." + git checkout "$ORIG_BRANCH" 2>/dev/null || true + if [ "${STASHED:-0}" -eq 1 ]; then + git stash pop 2>/dev/null || log_warn "Could not pop stash" + fi +} + +# =========================================================================== +# Python helper: handles ALL CLI interactions involving JSON +# =========================================================================== +run_python_phase() { + local phase="$1" # "issues" or "prs_fetch" or "pr_create" + shift + + python3 << 'PYEOF' +import json, subprocess, sys, os, textwrap + +CLI = os.environ['CLI_PATH'] +UPSTREAM_OWNER = os.environ['UPSTREAM_OWNER'] +TEST_OWNER = os.environ['TEST_OWNER'] +REPO = os.environ['REPO'] +ISSUE_COUNT = int(os.environ['ISSUE_COUNT']) +PR_COUNT = int(os.environ['PR_COUNT']) +DATA_DIR = os.environ['DATA_DIR'] +PHASE = os.environ['PY_PHASE'] + +def run_cli(*args, timeout=90): + """Run gitlink-cli, return parsed JSON. Exits on failure.""" + cmd = [CLI] + list(args) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if result.returncode != 0: + print(f"CLI error (exit={result.returncode}): {' '.join(cmd)}", file=sys.stderr) + if result.stderr: + print(f"stderr: {result.stderr.strip()[:500]}", file=sys.stderr) + if result.stdout: + print(f"stdout: {result.stdout.strip()[:500]}", file=sys.stderr) + sys.exit(1) + try: + return json.loads(result.stdout) + except json.JSONDecodeError as e: + print(f"JSON parse error: {e}", file=sys.stderr) + print(f"Command: {' '.join(cmd)}", file=sys.stderr) + print(f"stdout: {result.stdout[:800]}", file=sys.stderr) + sys.exit(1) + +# ========================================================================= +# PHASE: issues +# ========================================================================= +if PHASE == 'issues': + print("[INFO] Fetching upstream issues...") + result = run_cli('--owner', UPSTREAM_OWNER, '--repo', REPO, + 'issue', '+list', '--format', 'json', '--limit', str(ISSUE_COUNT)) + issues = result['data']['issues'] + print(f"[INFO] Got {len(issues)} issues from upstream") + + # Save raw upstream data + with open(os.path.join(DATA_DIR, 'upstream-issues.json'), 'w') as f: + json.dump(issues, f, ensure_ascii=False, indent=2) + + created = [] + for i, issue in enumerate(issues, 1): + number = issue['number'] + subject = issue.get('subject', '(no title)') + subject_short = subject[:80] + ('...' if len(subject) > 80 else '') + print(f"[INFO] [{i}/{len(issues)}] Fetching issue #{number}: {subject_short}") + + # Get full details (includes body/description) + try: + detail = run_cli('--owner', UPSTREAM_OWNER, '--repo', REPO, + 'issue', '+view', '--number', str(number), '--format', 'json') + issue_data = detail.get('data', detail) + body = (issue_data.get('description') or '').strip() + priority_id = str(issue_data.get('priority_id') or '2') + except Exception as e: + print(f"[WARN] Could not fetch details for #{number}: {e}, using list data only") + body = '' + priority_id = '2' + + # Create in test repo via python subprocess (safe body handling) + print(f"[INFO] [{i}/{len(issues)}] Creating in test repo...") + create_args = [ + '--owner', TEST_OWNER, '--repo', REPO, + 'issue', '+create', '--format', 'json', + '--title', subject, + '--priority-id', priority_id, + ] + if body: + create_args.extend(['--body', body]) + + try: + create_result = run_cli(*create_args, timeout=30) + new_data = create_result.get('data', create_result) + new_id = new_data.get('id') + new_number = new_data.get('project_issues_index') + print(f"[OK] Created issue #{new_number} (db_id={new_id})") + created.append({ + 'upstream_number': number, + 'upstream_db_id': issue['database_id'], + 'test_db_id': new_id, + 'test_number': new_number, + 'subject': subject, + }) + except Exception as e: + print(f"[ERROR] Failed to create issue #{number}: {e}") + created.append({ + 'upstream_number': number, + 'upstream_db_id': issue['database_id'], + 'error': str(e), + }) + + # Output marker + JSON for bash to capture + print("__PYTHON_RESULT__") + print(json.dumps(created, ensure_ascii=False)) + +# ========================================================================= +# PHASE: prs_fetch — fetch upstream PR data +# ========================================================================= +elif PHASE == 'prs_fetch': + print("[INFO] Fetching upstream PRs...") + result = run_cli('--owner', UPSTREAM_OWNER, '--repo', REPO, + 'pr', '+list', '--format', 'json', '--limit', str(PR_COUNT)) + prs = result['data']['pulls'] + print(f"[INFO] Got {len(prs)} PRs from upstream") + + with open(os.path.join(DATA_DIR, 'upstream-prs.json'), 'w') as f: + json.dump(prs, f, ensure_ascii=False, indent=2) + + # Return structured data for bash + pr_commands = [] + for i, pr in enumerate(prs, 1): + title = pr.get('title', '(no title)') + body = (pr.get('body') or '').strip() + head_branch = f"test-import/pr-{i}" + base_branch = pr.get('base', 'master') + + title_short = title[:80] + ('...' if len(title) > 80 else '') + print(f"[INFO] [{i}/{len(prs)}] {title_short}") + + pr_commands.append({ + 'index': i, + 'title': title, + 'body': body, + 'head': head_branch, + 'base': base_branch, + 'upstream_id': pr['id'], + 'upstream_index': pr['index'], + }) + + print("__PYTHON_RESULT__") + print(json.dumps(pr_commands, ensure_ascii=False)) + +# ========================================================================= +# PHASE: pr_create_one — create a single PR via CLI (called after git ops) +# ========================================================================= +elif PHASE == 'pr_create_one': + # Read PR data from temp file (avoids shell quoting issues with body text) + pr_data_file = os.environ['PR_DATA_FILE'] + with open(pr_data_file, 'r') as f: + pr = json.load(f) + + title = pr['title'] + body = pr.get('body', '') + head = pr['head'] + base = pr['base'] + + create_args = [ + '--owner', TEST_OWNER, '--repo', REPO, + 'pr', '+create', '--format', 'json', + '--title', title, + '--head', head, + '--base', base, + ] + if body: + create_args.extend(['--body', body]) + + try: + result = run_cli(*create_args, timeout=30) + pr_data = result.get('data', result) + created_number = pr_data.get('index') or pr_data.get('number') or pr_data.get('id') + print(f"[OK] Created PR #{created_number}") + print("__PYTHON_RESULT__") + print(json.dumps({'ok': True, 'number': str(created_number), 'title': title})) + except Exception as e: + print(f"[ERROR] Failed to create PR: {e}") + print("__PYTHON_RESULT__") + print(json.dumps({'ok': False, 'error': str(e)})) + +# ========================================================================= +# PHASE: build_manifest — final manifest construction +# ========================================================================= +elif PHASE == 'build_manifest': + issues_json = os.environ['ISSUES_JSON'] + prs_json = os.environ['PRS_JSON'] + from datetime import datetime + + issues = json.loads(issues_json) + prs = json.loads(prs_json) + + manifest = { + 'created_at': datetime.now().isoformat(), + 'upstream': 'gitlink/gitlink-cli', + 'test_repo': 'baoerjun/gitlink-cli', + 'issues': issues, + 'prs': prs, + 'summary': { + 'issues_imported': len([i for i in issues if 'test_db_id' in i]), + 'issues_failed': len([i for i in issues if 'error' in i]), + 'prs_created': len([p for p in prs if p.get('ok')]), + 'prs_failed': len([p for p in prs if not p.get('ok')]), + } + } + + manifest_path = os.environ['MANIFEST_PATH'] + with open(manifest_path, 'w') as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + + print(f"[OK] Manifest saved to {manifest_path}") + s = manifest['summary'] + print(f"[INFO] Issues: {s['issues_imported']} imported, {s['issues_failed']} failed") + print(f"[INFO] PRs: {s['prs_created']} created, {s['prs_failed']} failed") +PYEOF +} + +# --------------------------------------------------------------------------- +# Create a PR branch, push it, then call Python to create the PR via API +# --------------------------------------------------------------------------- +create_single_pr() { + local index="$1" + local branch="$2" + local title="$3" + local body="$4" + local base="$5" + + # All diagnostic output goes to stderr so stdout contains only JSON + { + log_info " [$index/$PR_COUNT] Creating branch '$branch' from '$base'..." + + # Clean up any previous attempt + git branch -D "$branch" 2>/dev/null || true + git push "$FORK_REMOTE" --delete "$branch" 2>/dev/null || true + + git checkout master + git checkout -b "$branch" + + # Create a dummy file as PR content + local marker_file="test-pr-import-${index}.md" + printf '%s\n' "# Test PR #${index}" "" "Imported from upstream gitlink/gitlink-cli." \ + "Upstream PR title: ${title}" > "$marker_file" + git add "$marker_file" + git commit -m "test: import PR #${index} from upstream" \ + -m "Source: gitlink/gitlink-cli" \ + -m "This is test data for gitlink-cli testing." --allow-empty + + log_info " [$index/$PR_COUNT] Pushing '$branch' to $FORK_REMOTE..." + git push "$FORK_REMOTE" "$branch" + } >&2 + + # Write PR data to temp file to avoid any shell quoting issues + local tmp_pr_data + tmp_pr_data=$(mktemp) + python3 -c " +import json, sys +data = json.load(sys.stdin) +json.dump({ + 'title': data['title'], + 'body': data['body'], + 'head': data['head'], + 'base': data['base'], +}, sys.stdout, ensure_ascii=False) +" <<< "$pr_data" > "$tmp_pr_data" + + # Now create the PR via Python (reads body from temp file, no shell interpolation) + log_info " [$index/$PR_COUNT] Creating PR via API..." >&2 + export PR_DATA_FILE="$tmp_pr_data" + export PY_PHASE="pr_create_one" + local api_result + api_result="$(run_python_phase "pr_create")" + rm -f "$tmp_pr_data" + + # Show progress from the Python output + echo "$api_result" | grep -E '^\[(OK|ERROR)\]' >&2 || true + + # Extract the result JSON (this goes to stdout for capture by caller) + local pr_json + pr_json=$(echo "$api_result" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2) + echo "$pr_json" +} + +# =========================================================================== +# Main +# =========================================================================== +main() { + echo "" + echo -e "${CYAN}========================================${NC}" + echo -e "${CYAN} GitLink Test Data Import Script ${NC}" + echo -e "${CYAN}========================================${NC}" + echo "" + log_info "Upstream : $UPSTREAM_OWNER/$REPO" + log_info "Test repo: $TEST_OWNER/$REPO" + log_info "Issues to import : $ISSUE_COUNT" + log_info "PRs to import : $PR_COUNT" + echo "" + + check_prereqs + check_existing_manifest + + # Export env vars for Python subprocess + export CLI_PATH="$CLI" + export UPSTREAM_OWNER TEST_OWNER REPO ISSUE_COUNT PR_COUNT DATA_DIR MANIFEST_PATH="$MANIFEST" + + # ===== Phase 1: Import Issues ===== + echo "" + log_step "===== Phase 1/4: Import Issues =====" + export PY_PHASE="issues" + local issues_output + issues_output="$(run_python_phase "issues")" + + # Show progress lines + echo "$issues_output" | grep -E '^\[(INFO|OK|WARN|ERROR)\]' || true + + # Extract result JSON + local issues_json + issues_json="$(echo "$issues_output" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2)" + + if [ -z "$issues_json" ]; then + log_error "Issue import produced no output. Check CLI connectivity." + exit 1 + fi + + local ok_count + ok_count=$(echo "$issues_json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len([i for i in d if 'test_db_id' in i]))") + log_info "Issues created: $ok_count / $ISSUE_COUNT" + + # ===== Phase 2: Fetch PR data from upstream ===== + echo "" + log_step "===== Phase 2/4: Fetch PR data from upstream =====" + export PY_PHASE="prs_fetch" + local prs_output + prs_output="$(run_python_phase "prs_fetch")" + echo "$prs_output" | grep -E '^\[(INFO|OK|WARN|ERROR)\]' || true + + local prs_json + prs_json="$(echo "$prs_output" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2)" + + if [ -z "$prs_json" ]; then + log_error "PR fetch produced no output." + exit 1 + fi + + local pr_count + pr_count=$(echo "$prs_json" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))") + log_info "PRs to create: $pr_count" + + # ===== Phase 3: Create PR branches, push, and create PRs ===== + echo "" + log_step "===== Phase 3/4: Create PRs (branches + API) =====" + + save_git_state + # Ensure we start from a clean master + git checkout master 2>/dev/null || true + + local pr_results_json="[" + local first=true + for idx in $(seq 0 $((pr_count - 1))); do + local pr_data index branch title body base + pr_data=$(echo "$prs_json" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)[$idx]))") + index=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['index'])") + branch=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['head'])") + title=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['title'])") + body=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['body'])") + base=$(echo "$pr_data" | python3 -c "import sys,json; print(json.load(sys.stdin)['base'])") + + local pr_result + pr_result=$(create_single_pr "$index" "$branch" "$title" "$body" "$base") + + if [ "$first" = true ]; then + first=false + else + pr_results_json+="," + fi + + # Parse the result and add to our collection + local pr_entry + pr_entry=$(echo "$pr_result" | python3 -c " +import sys, json +r = json.load(sys.stdin) +entry = { + 'index': $index, + 'branch': '${branch}', + 'title': r.get('title', ''), + 'ok': r.get('ok', False), + 'number': r.get('number', ''), + 'error': r.get('error', ''), +} +print(json.dumps(entry)) +" 2>/dev/null || echo "{\"index\": $index, \"branch\": \"$branch\", \"ok\": false, \"error\": \"parse failed\"}") + pr_results_json+="$pr_entry" + done + pr_results_json+="]" + + restore_git_state + + # ===== Phase 4: Build Manifest ===== + echo "" + log_step "===== Phase 4/4: Save Manifest =====" + + # Normalize PR results JSON + pr_results_json=$(echo "$pr_results_json" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)))" 2>/dev/null || echo "$pr_results_json") + + export ISSUES_JSON="$issues_json" + export PRS_JSON="$pr_results_json" + export PY_PHASE="build_manifest" + run_python_phase "build_manifest" + + # ===== Summary ===== + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} Import Complete! ${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + log_info "Manifest: $MANIFEST" + log_info "Verify with:" + echo "" + echo " ${CLI} --owner ${TEST_OWNER} --repo ${REPO} issue +list --limit 5" + echo " ${CLI} --owner ${TEST_OWNER} --repo ${REPO} pr +list --limit 5" + echo "" +} + +main "$@" diff --git a/scripts/restore-test-repo.sh b/scripts/restore-test-repo.sh new file mode 100644 index 0000000..50ea2d3 --- /dev/null +++ b/scripts/restore-test-repo.sh @@ -0,0 +1,461 @@ +#!/usr/bin/env bash +# ============================================================================ +# restore-test-repo.sh +# Delete all issues and PRs from the test repo (baoerjun/gitlink-cli), +# then re-import clean test data from upstream. +# +# Usage: +# ./scripts/restore-test-repo.sh [--yes] [--keep-branches] +# +# --yes Skip confirmation prompts +# --keep-branches Don't delete test-import/* branches (keep local + remote) +# --no-reimport Skip re-import after cleanup (just delete, don't repopulate) +# ============================================================================ +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +DATA_DIR="$PROJECT_DIR/data" +CLI="${PROJECT_DIR}/gitlink-cli" +IMPORT_SCRIPT="$SCRIPT_DIR/import-test-data.sh" +MANIFEST="$DATA_DIR/test-data-manifest.json" + +TEST_OWNER="baoerjun" +REPO="gitlink-cli" +FORK_REMOTE="fork" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $*"; } + +YES_MODE=0 +KEEP_BRANCHES=0 +NO_REIMPORT=0 + +for arg in "$@"; do + case "$arg" in + --yes) YES_MODE=1 ;; + --keep-branches) KEEP_BRANCHES=1 ;; + --no-reimport) NO_REIMPORT=1 ;; + *) log_error "Unknown option: $arg"; exit 1 ;; + esac +done + +# --------------------------------------------------------------------------- +# Confirmation helper +# --------------------------------------------------------------------------- +confirm() { + if [ "$YES_MODE" -eq 1 ]; then + return 0 + fi + local prompt="$1" + read -r -p "$prompt [y/N] " answer + [[ "$answer" =~ ^[Yy]$ ]] +} + +# --------------------------------------------------------------------------- +# Prerequisite checks +# --------------------------------------------------------------------------- +check_prereqs() { + if [ ! -f "$CLI" ]; then + log_error "gitlink-cli binary not found at $CLI" + exit 1 + fi + if [ ! -f "$MANIFEST" ]; then + log_warn "No manifest found at $MANIFEST" + log_warn "Will proceed with full cleanup (delete ALL issues/PRs in test repo)." + if ! confirm "Continue without manifest?"; then + exit 0 + fi + fi +} + +# --------------------------------------------------------------------------- +# Show what will be deleted +# --------------------------------------------------------------------------- +show_plan() { + echo "" + echo -e "${YELLOW}========================================${NC}" + echo -e "${YELLOW} RESTORE PLAN ${NC}" + echo -e "${YELLOW}========================================${NC}" + echo "" + + # Count issues + local issue_count + issue_count=$("$CLI" --owner "$TEST_OWNER" --repo "$REPO" \ + issue +list --format json --limit 1 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('total_count',0))" 2>/dev/null || echo "?") + echo -e " Issues to delete: ${RED}${issue_count}${NC}" + + # Count open PRs + local pr_count + pr_count=$("$CLI" --owner "$TEST_OWNER" --repo "$REPO" \ + pr +list --format json --limit 1 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('data',{}).get('total_count',0))" 2>/dev/null || echo "?") + echo -e " PRs to close: ${RED}${pr_count}${NC}" + + # Branches + local branch_count + branch_count=$(git branch -a 2>/dev/null | grep -c 'test-import/pr-' || echo "0") + echo -e " Local branches: ${RED}${branch_count}${NC} (test-import/pr-*)" + + if [ "$NO_REIMPORT" -eq 0 ]; then + echo "" + echo -e " After cleanup: re-import ${GREEN}15 issues + 6 PRs${NC} from upstream" + else + echo "" + echo -e " ${YELLOW}--no-reimport: will NOT repopulate after cleanup${NC}" + fi + echo "" +} + +# --------------------------------------------------------------------------- +# Phase 1: Delete all issues +# --------------------------------------------------------------------------- +delete_all_issues() { + log_step "Phase 1/4: Deleting all issues from $TEST_OWNER/$REPO ..." + + python3 << 'PYEOF' +import json, subprocess, sys, os + +CLI = os.environ['CLI_PATH'] +TEST_OWNER = os.environ['TEST_OWNER'] +REPO = os.environ['REPO'] +YES_MODE = os.environ.get('YES_MODE', '0') + +def run_cli(*args, timeout=60): + cmd = [CLI] + list(args) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if result.returncode != 0: + print(f"CLI error: {' '.join(cmd)}", file=sys.stderr) + print(f"stderr: {result.stderr.strip()[:500]}", file=sys.stderr) + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return None + +# First get the total count +result = run_cli('--owner', TEST_OWNER, '--repo', REPO, + 'issue', '+list', '--format', 'json', '--limit', '1') +if not result: + print("[INFO] Could not query issues. Repo may already be empty.") + print("__PYTHON_RESULT__") + print(json.dumps({'deleted': 0})) + sys.exit(0) + +total = result.get('data', {}).get('total_count', 0) +print(f"[INFO] Total issues in test repo: {total}") + +if total == 0: + print("[INFO] No issues to delete.") + print("__PYTHON_RESULT__") + print(json.dumps({'deleted': 0})) + sys.exit(0) + +# Fetch ALL issues page by page (API supports --page, not offset) +PAGE_SIZE = 50 +all_ids = [] +page_num = 1 + +while len(all_ids) < total: + print(f"[INFO] Fetching page {page_num} (limit={PAGE_SIZE})...") + result = run_cli('--owner', TEST_OWNER, '--repo', REPO, + 'issue', '+list', '--format', 'json', + '--limit', str(PAGE_SIZE), + '--page', str(page_num)) + + if not result: + print(f"[WARN] Failed to fetch page {page_num}") + break + + issues = result.get('data', {}).get('issues', []) + if not issues: + break # No more issues + + page_ids = [str(issue['database_id']) for issue in issues] + all_ids.extend(page_ids) + print(f"[INFO] Page {page_num}: fetched {len(page_ids)} issue IDs") + page_num += 1 + +print(f"[INFO] Total issue IDs collected: {len(all_ids)}") + +if not all_ids: + print("[INFO] No issues to delete.") + print("__PYTHON_RESULT__") + print(json.dumps({'deleted': 0})) + sys.exit(0) + +# Batch delete (API may have limits on batch size, do in chunks) +BATCH_DELETE_SIZE = 50 +deleted_count = 0 +failed_ids = [] + +for i in range(0, len(all_ids), BATCH_DELETE_SIZE): + chunk = all_ids[i:i + BATCH_DELETE_SIZE] + ids_str = ','.join(chunk) + print(f"[INFO] Deleting batch {i // BATCH_DELETE_SIZE + 1} ({len(chunk)} issues)...") + + result = run_cli('--owner', TEST_OWNER, '--repo', REPO, + 'issue', '+batch-delete', + '--ids', ids_str, + '--confirm', + timeout=120) + + if result and result.get('ok'): + deleted_count += len(chunk) + print(f"[OK] Deleted {len(chunk)} issues") + else: + print(f"[WARN] Batch delete may have partially failed for {len(chunk)} issues") + failed_ids.extend(chunk) + +# If batch delete failed, try individual deletes (fallback) +if failed_ids: + print(f"[WARN] {len(failed_ids)} issues failed batch delete, trying individual...") + # There's no individual delete; just report the failure + print(f"[WARN] Could not delete {len(failed_ids)} issues. Manual cleanup may be needed.") + +print(f"[INFO] Successfully deleted: {deleted_count} issues") + +print("__PYTHON_RESULT__") +print(json.dumps({'deleted': deleted_count, 'failed': len(failed_ids)})) +PYEOF +} + +# --------------------------------------------------------------------------- +# Phase 2: Close all PRs +# --------------------------------------------------------------------------- +close_all_prs() { + log_step "Phase 2/4: Closing all PRs in $TEST_OWNER/$REPO ..." + + python3 << 'PYEOF' +import json, subprocess, sys, os + +CLI = os.environ['CLI_PATH'] +TEST_OWNER = os.environ['TEST_OWNER'] +REPO = os.environ['REPO'] + +def run_cli(*args, timeout=60): + cmd = [CLI] + list(args) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if result.returncode != 0: + print(f"CLI error (non-fatal): {' '.join(cmd)}", file=sys.stderr) + print(f"stderr: {result.stderr.strip()[:300]}", file=sys.stderr) + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return None + +# Get total count +result = run_cli('--owner', TEST_OWNER, '--repo', REPO, + 'pr', '+list', '--format', 'json', '--limit', '1') +if not result: + print("[INFO] Could not query PRs. Repo may have none.") + print("__PYTHON_RESULT__") + print(json.dumps({'closed': 0})) + sys.exit(0) + +total = result.get('data', {}).get('total_count', 0) +print(f"[INFO] Total PRs in test repo: {total}") + +if total == 0: + print("[INFO] No PRs to close.") + print("__PYTHON_RESULT__") + print(json.dumps({'closed': 0})) + sys.exit(0) + +# Fetch all open PRs +result = run_cli('--owner', TEST_OWNER, '--repo', REPO, + 'pr', '+list', '--format', 'json', '--limit', str(max(total, 200))) + +if not result: + print("[ERROR] Failed to fetch PR list") + print("__PYTHON_RESULT__") + print(json.dumps({'closed': 0, 'error': 'fetch failed'})) + sys.exit(1) + +prs = result.get('data', {}).get('pulls', []) + +# Only close open PRs +open_prs = [p for p in prs if p.get('status') == 'open'] +print(f"[INFO] Open PRs to close: {len(open_prs)}") + +closed = 0 +failed = 0 + +for pr in open_prs: + pr_index = pr.get('index') or pr.get('number') + title_short = (pr.get('title', '')[:60]) + ('...' if len(pr.get('title', '')) > 60 else '') + print(f"[INFO] Closing PR #{pr_index}: {title_short}") + + result = run_cli('--owner', TEST_OWNER, '--repo', REPO, + 'pr', '+refuse', '--id', str(pr_index), timeout=60) + if result and result.get('ok'): + closed += 1 + print(f"[OK] Closed PR #{pr_index}") + else: + failed += 1 + print(f"[WARN] Failed to close PR #{pr_index}") + +print(f"[INFO] Closed: {closed}, Failed: {failed}") + +print("__PYTHON_RESULT__") +print(json.dumps({'closed': closed, 'failed': failed})) +PYEOF +} + +# --------------------------------------------------------------------------- +# Phase 3: Delete test branches +# --------------------------------------------------------------------------- +delete_test_branches() { + log_step "Phase 3/4: Cleaning up test branches ..." + + # Find local test-import branches + local local_branches + local_branches=$(git branch 2>/dev/null | grep 'test-import/pr-' | sed 's/^[* ]*//' || true) + + if [ -n "$local_branches" ]; then + echo "$local_branches" | while read -r branch; do + [ -z "$branch" ] && continue + log_info " Deleting local branch: $branch" + git branch -D "$branch" 2>/dev/null || log_warn " Could not delete local branch $branch" + done + else + log_info " No local test-import/pr-* branches found." + fi + + # Find and delete remote branches + local remote_branches + remote_branches=$(git ls-remote --heads "$FORK_REMOTE" 2>/dev/null \ + | grep 'refs/heads/test-import/pr-' \ + | sed 's/.*refs\/heads\///' || true) + + if [ -n "$remote_branches" ]; then + echo "$remote_branches" | while read -r branch; do + [ -z "$branch" ] && continue + log_info " Deleting remote branch: $FORK_REMOTE/$branch" + git push "$FORK_REMOTE" --delete "$branch" 2>/dev/null \ + || log_warn " Could not delete remote branch $branch" + done + else + log_info " No remote test-import/pr-* branches on $FORK_REMOTE." + fi + + # Clean up test PR marker files + log_info " Cleaning up test PR marker files..." + git checkout master 2>/dev/null || true + if ls test-pr-import-*.md &>/dev/null 2>&1; then + rm -f test-pr-import-*.md + log_info " Removed test-pr-import-*.md files" + fi +} + +# --------------------------------------------------------------------------- +# Phase 4: Re-import +# --------------------------------------------------------------------------- +reimport_test_data() { + log_step "Phase 4/4: Re-importing clean test data ..." + + # Remove old manifest so import starts fresh + rm -f "$MANIFEST" + + if [ ! -f "$IMPORT_SCRIPT" ]; then + log_error "Import script not found at $IMPORT_SCRIPT" + exit 1 + fi + + log_info "Running: $IMPORT_SCRIPT" + echo "" + + # Pass through --yes if we're in yes mode + if [ "$YES_MODE" -eq 1 ]; then + bash "$IMPORT_SCRIPT" + else + bash "$IMPORT_SCRIPT" + fi +} + +# =========================================================================== +# Main +# =========================================================================== +main() { + echo "" + echo -e "${RED}========================================${NC}" + echo -e "${RED} GitLink Test Repo RESTORE Script ${NC}" + echo -e "${RED}========================================${NC}" + echo "" + log_warn "This will DELETE ALL issues and close ALL PRs in:" + log_warn " $TEST_OWNER/$REPO" + echo "" + + check_prereqs + show_plan + + if ! confirm "Proceed with restore?"; then + log_info "Aborted." + exit 0 + fi + + echo "" + echo -e "${RED}>>> Starting restore in 2 seconds...${NC}" + sleep 2 + + # Export env vars for Python subprocess + export CLI_PATH="$CLI" + export TEST_OWNER REPO YES_MODE="$YES_MODE" + + # ---- Phase 1: Delete Issues ---- + echo "" + local issues_output + issues_output=$(delete_all_issues) + echo "$issues_output" | grep -E '^\[(INFO|OK|WARN|ERROR)\]' || true + local issues_result + issues_result=$(echo "$issues_output" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2) + local deleted_issues + deleted_issues=$(echo "$issues_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('deleted',0))" 2>/dev/null || echo "0") + log_info "Issues deleted: $deleted_issues" + + # ---- Phase 2: Close PRs ---- + echo "" + local prs_output + prs_output=$(close_all_prs) + echo "$prs_output" | grep -E '^\[(INFO|OK|WARN|ERROR)\]' || true + local prs_result + prs_result=$(echo "$prs_output" | sed -n '/^__PYTHON_RESULT__$/,$p' | tail -n +2) + log_info "PRs closed: $(echo "$prs_result" | python3 -c "import sys,json; print(json.load(sys.stdin).get('closed',0))" 2>/dev/null || echo "0")" + + # ---- Phase 3: Clean up branches ---- + echo "" + if [ "$KEEP_BRANCHES" -eq 0 ]; then + delete_test_branches + else + log_info "Phase 3/4: Skipping branch cleanup (--keep-branches)" + fi + + # ---- Phase 4: Re-import ---- + echo "" + if [ "$NO_REIMPORT" -eq 0 ]; then + reimport_test_data + else + log_info "Phase 4/4: Skipping re-import (--no-reimport)" + fi + + # ---- Summary ---- + echo "" + echo -e "${GREEN}========================================${NC}" + echo -e "${GREEN} Restore Complete! ${NC}" + echo -e "${GREEN}========================================${NC}" + echo "" + log_info "Issues deleted: $deleted_issues" + if [ "$NO_REIMPORT" -eq 0 ]; then + log_info "Test data re-imported: 15 issues + 6 PRs" + else + log_warn "Re-import skipped. Test repo is now empty." + fi + echo "" +} + +main diff --git a/workflows/community-ops-sweep/community-ops-sweep.wf.js b/workflows/community-ops-sweep/community-ops-sweep.wf.js index aa92893..e7a3ac7 100644 --- a/workflows/community-ops-sweep/community-ops-sweep.wf.js +++ b/workflows/community-ops-sweep/community-ops-sweep.wf.js @@ -23,7 +23,7 @@ const REPORT_FLAG = REPORT_ISSUE ? `--report-issue ${REPORT_ISSUE}` : '' const RELEASE_FLAG = RELEASE_ID ? `--release-id ${RELEASE_ID}` : '' const WIKI_FLAG = PUBLISH_WIKI ? `--publish-wiki --wiki-dir ${WIKI_DIR}` : '' -const ENGINE = 'examples/workflows/community-ops-sweep/scripts/community_ops_sweep.py' +const ENGINE = 'workflows/community-ops-sweep/scripts/community_ops_sweep.py' const CANDIDATES = '/tmp/sweep-candidates.json' const TRIAGE = '/tmp/sweep-triage.json' const OWNERS = '/tmp/sweep-owners.json' @@ -136,7 +136,7 @@ log(`Summary: ${summaryOut}`) phase('Plan') const planOut = await agent( `Run the sweep engine plan:\n` + - ` python3 ${ENGINE} plan --candidates ${CANDIDATES} --triage ${TRIAGE} --owners ${OWNERS} --links ${LINKS} ${SUMMARY_ARG} --out ${PLAN}\n` + + ` python3 ${ENGINE} plan --candidates ${CANDIDATES} --triage ${TRIAGE} --owners ${OWNERS} --links ${LINKS} ${SUMMARY_ARG} --routing workflows/community-ops-sweep/routing-rules.example.yaml --out ${PLAN}\n` + `Then Read ${PLAN} and summarize: how many writes, by op (update_tags/update_status/comment/update_assigner), which issues affected. Show the writes table.`, { label: 'plan', phase: 'Plan' } ) diff --git a/workflows/community-ops-sweep/scripts/gitlink_workflow.py b/workflows/community-ops-sweep/scripts/gitlink_workflow.py new file mode 100644 index 0000000..d27be60 --- /dev/null +++ b/workflows/community-ops-sweep/scripts/gitlink_workflow.py @@ -0,0 +1,814 @@ +from __future__ import annotations + +import argparse +import json +import os +import subprocess +from collections import Counter, defaultdict +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable + + +class WorkflowError(RuntimeError): + pass + + +CLI_PAGE_SIZE = 100 + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示" + ) + parser.add_argument( + "--config", + type=Path, + default=Path("examples/sample_config.json"), + help="配置文件路径", + ) + parser.add_argument("--owner", help="覆盖配置中的仓库所有者") + parser.add_argument("--repo", help="覆盖配置中的仓库名称") + parser.add_argument( + "--window-days", + type=int, + help="统计窗口,默认从配置文件读取或使用 7 天", + ) + parser.add_argument( + "--output-dir", + type=Path, + help="输出目录,默认从配置文件读取或使用 outputs", + ) + parser.add_argument( + "--publish-issue-id", + type=int, + help="发布摘要到指定 Issue 评论,未提供则只生成本地报告", + ) + parser.add_argument( + "--now", + help="固定当前时间,便于测试,格式为 ISO8601", + ) + parser.add_argument( + "--skip-releases", + action="store_true", + help="跳过 release 列表采集", + ) + parser.add_argument( + "--cli-bin", + help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用", + ) + return parser.parse_args(argv) + + +def load_json_file(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def sanitize_repo_name(value: str) -> str: + return value.replace("/", "_").replace("\\", "_") + + +def parse_datetime(value: Any) -> datetime | None: + if value in (None, "", []): + return None + if isinstance(value, datetime): + dt = value + else: + text = str(value).strip() + if not text: + return None + text = text.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def parse_iso_now(value: str | None) -> datetime: + if not value: + return datetime.now(timezone.utc) + dt = parse_datetime(value) + if dt is None: + raise WorkflowError(f"无法解析 --now 的值: {value}") + return dt + + +def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any: + for key in keys: + if key in item: + value = item[key] + if value not in (None, "", []): + return value + return default + + +def normalize_labels(value: Any) -> list[str]: + labels: list[str] = [] + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + name = first_value(item, ("name", "title", "label_name")) + if name: + labels.append(str(name)) + elif item not in (None, ""): + labels.append(str(item)) + elif isinstance(value, str) and value: + labels.append(value) + return labels + + +def extract_first_list(payload: Any, keys: Iterable[str]) -> list[Any]: + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return value + for value in payload.values(): + found = extract_first_list(value, keys) + if found: + return found + return [] + + +def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]: + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, dict): + return value + for value in payload.values(): + found = extract_first_dict(value, keys) + if found: + return found + if isinstance(payload, list): + for item in payload: + found = extract_first_dict(item, keys) + if found: + return found + return {} + + +def run_gitlink_cli(command: list[str], owner: str, repo: str, cwd: Path | None = None) -> Any: + if shutil_which("gitlink-cli") is None: + raise WorkflowError("未找到 gitlink-cli,请先安装并确保它在 PATH 中") + + cli_path = shutil_which("gitlink-cli") or "gitlink-cli" + if cli_path.lower().endswith((".cmd", ".bat")): + cmd = [ + "cmd", + "/c", + cli_path, + *command, + "--owner", + owner, + "--repo", + repo, + "--format", + "json", + ] + else: + cmd = [ + cli_path, + *command, + "--owner", + owner, + "--repo", + repo, + "--format", + "json", + ] + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + encoding="utf-8", + ) + if proc.returncode != 0: + stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误" + raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}") + return parse_json_output(proc.stdout) + + +def parse_json_output(text: str) -> Any: + stripped = text.strip() + if not stripped: + raise WorkflowError("CLI 返回空结果") + try: + return json.loads(stripped) + except json.JSONDecodeError: + first_json = min( + [idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1], + default=-1, + ) + if first_json > 0: + return json.loads(stripped[first_json:]) + raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}") + + +def normalize_repo_info(payload: Any) -> dict[str, Any]: + repo = extract_first_dict(payload, ("project", "repo", "repository", "data")) + if not repo and isinstance(payload, dict): + repo = payload + return { + "name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""), + "description": first_value(repo, ("description", "desc", "summary"), ""), + "default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""), + "language": first_value(repo, ("language",), ""), + "raw": repo, + } + + +def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str: + raw_status = first_value(item, ("status_id", "status", "state_id"), None) + raw_name = str( + first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "") + ).strip().lower() + if raw_status is not None: + try: + raw_status = int(raw_status) + except (TypeError, ValueError): + raw_status = str(raw_status).strip().lower() + if raw_status in {5, "5", "closed", "close"} or "关" in raw_name or "closed" in raw_name: + return "closed" + if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "开" in raw_name or "新" in raw_name: + return "open" + if query_state: + return query_state + return "open" + + +def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]: + return { + "id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")), + "title": str(first_value(item, ("subject", "title", "name"), "(untitled)")), + "state": normalize_issue_state(item, query_state=query_state), + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time")) + ), + "updated_at": parse_datetime( + first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time")) + ), + "labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])), + "raw": item, + } + + +def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("issues", "issue_list", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append(normalize_issue(item, query_state=query_state)) + return normalized + + +def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str: + raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None) + if raw_status is not None: + try: + raw_status = int(raw_status) + except (TypeError, ValueError): + raw_status = str(raw_status).strip().lower() + if raw_status in {1, "1", "merged"}: + return "merged" + if raw_status in {2, "2", "closed", "close"}: + return "closed" + if raw_status in {0, "0", "open", "opened"}: + return "open" + if query_state: + return query_state + return "open" + + +def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]: + state = normalize_pr_state(item, query_state=query_state) + merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time"))) + merged_flag = state == "merged" or merged_at is not None + return { + "id": str( + first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "") + ), + "title": str(first_value(item, ("title", "subject", "name"), "(untitled)")), + "state": state, + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time")) + ), + "updated_at": parse_datetime( + first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time")) + ), + "merged_at": merged_at + or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None), + "merged": merged_flag, + "labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])), + "raw": item, + } + + +def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append(normalize_pr(item, query_state=query_state)) + return normalized + + +def normalize_releases(payload: Any) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("releases", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append( + { + "id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")), + "title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")), + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "released_at", "releasedAt")) + ), + "raw": item, + } + ) + return normalized + + +def is_open(state: str) -> bool: + return state == "open" + + +def is_closed(state: str) -> bool: + return state in {"closed", "close", "done", "resolved"} + + +def classify_title(title: str) -> str: + lowered = title.strip().lower() + prefix = lowered.split(":", 1)[0] + prefix = prefix.split("(", 1)[0].strip() + mapping = { + "feat": "feature", + "feature": "feature", + "fix": "fix", + "bugfix": "fix", + "docs": "docs", + "doc": "docs", + "refactor": "refactor", + "test": "test", + "chore": "chore", + "ci": "ci", + } + return mapping.get(prefix, "other") + + +def within_window(dt: datetime | None, cutoff: datetime) -> bool: + return dt is not None and dt >= cutoff + + +def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[str] = set() + result: list[dict[str, Any]] = [] + for item in records: + key = str(item.get("id", "")).strip() + if not key or key in seen: + continue + seen.add(key) + result.append(item) + return result + + +def fetch_paginated_payload( + command: list[str], + owner: str, + repo: str, + item_keys: tuple[str, ...], + page_size: int = CLI_PAGE_SIZE, +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + page = 1 + max_pages = 50 + while True: + if page > max_pages: + break + payload = run_gitlink_cli( + [*command, "--page", str(page), "--limit", str(page_size)], + owner, + repo, + ) + page_items = extract_first_list(payload, item_keys) + page_items = [item for item in page_items if isinstance(item, dict)] + if not page_items: + break + items.extend(page_items) + if len(page_items) < page_size: + break + page += 1 + return items + + +def fetch_issues(owner: str, repo: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for state in ("open", "closed"): + payloads = fetch_paginated_payload( + ["issue", "+list", "--state", state], + owner, + repo, + ("issues", "issue_list", "items", "list"), + ) + records.extend(normalize_issues({"issues": payloads}, query_state=state)) + return dedupe_records(records) + + +def fetch_prs(owner: str, repo: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for state in ("open", "merged", "closed"): + payloads = fetch_paginated_payload( + ["pr", "+list", "--state", state], + owner, + repo, + ("pull_requests", "merge_requests", "prs", "items", "list"), + ) + records.extend(normalize_prs({"pull_requests": payloads}, query_state=state)) + return dedupe_records(records) + + +def fetch_releases(owner: str, repo: str) -> list[dict[str, Any]]: + payloads = fetch_paginated_payload( + ["release", "+list"], + owner, + repo, + ("releases", "items", "list"), + ) + return dedupe_records(normalize_releases({"releases": payloads})) + + +def summarize_workflow( + repo_info: dict[str, Any], + issues: list[dict[str, Any]], + prs: list[dict[str, Any]], + releases: list[dict[str, Any]], + now: datetime, + window_days: int, +) -> dict[str, Any]: + cutoff = now - timedelta(days=window_days) + + open_issues = [item for item in issues if is_open(item["state"])] + closed_issues = [item for item in issues if is_closed(item["state"])] + stale_issues = [ + item + for item in open_issues + if item["updated_at"] is None or item["updated_at"] < cutoff + ] + + merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"] + open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))] + stale_prs = [ + item + for item in open_prs + if item["updated_at"] is None or item["updated_at"] < cutoff + ] + recent_merged_prs = [ + item + for item in merged_prs + if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff) + ] + + issue_label_counter: Counter[str] = Counter() + for item in issues: + issue_label_counter.update(item["labels"]) + + pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list) + for item in recent_merged_prs: + pr_buckets[classify_title(item["title"])].append(item) + + actions: list[str] = [] + if stale_issues: + actions.append( + f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue,建议优先清理。" + ) + if stale_prs: + actions.append( + f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR,建议安排 review 或重新拆解。" + ) + if not releases: + actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。") + + return { + "repo": repo_info, + "window_days": window_days, + "now": now, + "cutoff": cutoff, + "counts": { + "issues_total": len(issues), + "issues_open": len(open_issues), + "issues_closed": len(closed_issues), + "issues_stale": len(stale_issues), + "prs_total": len(prs), + "prs_open": len(open_prs), + "prs_merged": len(merged_prs), + "prs_stale": len(stale_prs), + "releases_total": len(releases), + }, + "labels": issue_label_counter.most_common(8), + "stale_issues": stale_issues, + "stale_prs": stale_prs, + "recent_merged_prs": recent_merged_prs, + "pr_buckets": {key: value for key, value in pr_buckets.items()}, + "actions": actions, + } + + +def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str: + if not items: + return "- 无" + lines = [] + for item in items[:10]: + parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()] + state = item.get("state") + if state: + parts.append(f"({state})") + dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at") + if isinstance(dt, datetime): + parts.append(dt.strftime("%Y-%m-%d")) + lines.append(" ".join(parts)) + return "\n".join(lines) + + +def render_markdown_report(summary: dict[str, Any]) -> str: + repo = summary["repo"] + counts = summary["counts"] + lines: list[str] = [] + title = repo["name"] or "GitLink 仓库" + lines.append(f"# {title} 自动化周报") + if repo.get("description"): + lines.append("") + lines.append(repo["description"]) + lines.append("") + lines.append(f"- 统计窗口:近 {summary['window_days']} 天") + lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}") + lines.append("") + lines.append("## 核心指标") + lines.append("") + lines.append("| 指标 | 数值 |") + lines.append("| --- | ---: |") + lines.append(f"| Issues 总数 | {counts['issues_total']} |") + lines.append(f"| 打开 Issues | {counts['issues_open']} |") + lines.append(f"| 超窗 Issue | {counts['issues_stale']} |") + lines.append(f"| PR 总数 | {counts['prs_total']} |") + lines.append(f"| 打开 PR | {counts['prs_open']} |") + lines.append(f"| 已合并 PR | {counts['prs_merged']} |") + lines.append(f"| Release 数 | {counts['releases_total']} |") + lines.append("") + + lines.append("## 热点标签") + if summary["labels"]: + for label, count in summary["labels"]: + lines.append(f"- {label}: {count}") + else: + lines.append("- 无") + lines.append("") + + lines.append("## 最近合并 PR") + recent_groups = summary["pr_buckets"] + if recent_groups: + for bucket, items in recent_groups.items(): + lines.append(f"### {bucket}") + for item in items[:8]: + merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at") + suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else "" + lines.append(f"- {item['title']}{suffix}") + else: + lines.append("- 无") + lines.append("") + + lines.append("## 风险提示") + if summary["stale_issues"]: + lines.append("### 超窗 Issue") + lines.append(render_list_block(summary["stale_issues"])) + lines.append("") + if summary["stale_prs"]: + lines.append("### 超窗 PR") + lines.append(render_list_block(summary["stale_prs"])) + lines.append("") + if summary["actions"]: + lines.append("### 建议动作") + for action in summary["actions"]: + lines.append(f"- {action}") + else: + lines.append("- 当前未发现明显风险。") + return "\n".join(lines).rstrip() + "\n" + + +def render_release_notes(summary: dict[str, Any]) -> str: + repo = summary["repo"] + lines: list[str] = [] + title = repo["name"] or "GitLink 仓库" + lines.append(f"# {title} Release Notes 草稿") + lines.append("") + lines.append(f"- 统计窗口:近 {summary['window_days']} 天") + lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}") + lines.append("") + lines.append("## 变更概览") + lines.append(f"- 已合并 PR:{summary['counts']['prs_merged']} 个") + lines.append(f"- 最近窗口内合并 PR:{len(summary['recent_merged_prs'])} 个") + lines.append("") + lines.append("## 变更分类") + groups = summary["pr_buckets"] + if groups: + for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"): + items = groups.get(bucket, []) + if not items: + continue + lines.append(f"### {bucket}") + for item in items[:10]: + merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at") + suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else "" + lines.append(f"- {item['title']}{suffix}") + lines.append("") + else: + lines.append("- 无") + lines.append("") + lines.append("## 发布说明") + if summary["actions"]: + for action in summary["actions"]: + lines.append(f"- {action}") + else: + lines.append("- 当前未发现明显风险。") + return "\n".join(lines).rstrip() + "\n" + + +def render_publish_comment( + summary: dict[str, Any], + report_path: Path, + release_notes_path: Path | None = None, +) -> str: + repo = summary["repo"] + counts = summary["counts"] + lines = [ + f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要", + "", + f"- 时间窗:近 {summary['window_days']} 天", + f"- Issues:{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗", + f"- PR:{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并", + f"- Release:{counts['releases_total']} 条", + "", + f"完整报告已生成:`{report_path.as_posix()}`", + ] + if release_notes_path is not None: + lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`") + if summary["actions"]: + lines.append("") + lines.append("### 建议动作") + for action in summary["actions"][:3]: + lines.append(f"- {action}") + return "\n".join(lines).rstrip() + + +def build_issue_comment_command(issue_number: int, comment: str) -> list[str]: + return ["issue", "+comment", "--number", str(issue_number), "--body", comment] + + +def safe_fetch( + label: str, + func, + warnings: list[str], + default: Any, +) -> Any: + try: + return func() + except Exception as exc: # noqa: BLE001 + warnings.append(f"{label} 失败:{exc}") + return default + + +def shutil_which(name: str) -> str | None: + from shutil import which + + return which(name) + + +def build_artifacts( + owner: str, + repo: str, + window_days: int, + output_dir: Path, + now: datetime, + publish_issue_id: int | None, + skip_releases: bool, +) -> tuple[dict[str, Any], Path, Path, Path, list[str]]: + warnings: list[str] = [] + repo_info = safe_fetch( + "repo +info", + lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo)), + warnings, + {"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}}, + ) + issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo), warnings, []) + prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo), warnings, []) + releases = [] if skip_releases else safe_fetch( + "release +list", + lambda: fetch_releases(owner, repo), + warnings, + [], + ) + + summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days) + summary["warnings"] = warnings + summary["owner"] = owner + summary["repo_name"] = repo + summary["publish_issue_id"] = publish_issue_id + + output_dir.mkdir(parents=True, exist_ok=True) + stamp = now.strftime("%Y%m%d_%H%M%S") + repo_slug = sanitize_repo_name(repo) + base_name = f"{owner}_{repo_slug}_{stamp}" + report_path = output_dir / f"{base_name}_report.md" + summary_path = output_dir / f"{base_name}_summary.json" + release_notes_path = output_dir / f"{base_name}_release_notes.md" + + report_text = render_markdown_report(summary) + release_notes_text = render_release_notes(summary) + report_path.write_text(report_text, encoding="utf-8") + release_notes_path.write_text(release_notes_text, encoding="utf-8") + summary_path.write_text( + json.dumps( + { + **summary, + "now": summary["now"].isoformat(), + "cutoff": summary["cutoff"].isoformat(), + "artifacts": { + "report": report_path.as_posix(), + "summary": summary_path.as_posix(), + "release_notes": release_notes_path.as_posix(), + }, + }, + ensure_ascii=False, + indent=2, + default=str, + ), + encoding="utf-8", + ) + + if publish_issue_id is not None: + comment = render_publish_comment(summary, report_path, release_notes_path) + try: + run_gitlink_cli( + build_issue_comment_command(publish_issue_id, comment), + owner, + repo, + ) + except Exception as exc: # noqa: BLE001 + warnings.append(f"issue +comment 失败:{exc}") + + return summary, report_path, summary_path, release_notes_path, warnings + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + config = load_json_file(args.config) + + owner = args.owner or config.get("owner") + repo = args.repo or config.get("repo") + if not owner or not repo: + raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo") + + window_days = args.window_days or int(config.get("window_days", 7)) + output_dir = args.output_dir or Path(config.get("output_dir", "outputs")) + now = parse_iso_now(args.now) + + summary, report_path, summary_path, release_notes_path, warnings = build_artifacts( + owner=owner, + repo=repo, + window_days=window_days, + output_dir=output_dir, + now=now, + publish_issue_id=args.publish_issue_id, + skip_releases=args.skip_releases, + ) + + print(f"已生成报告: {report_path}") + print(f"已生成摘要: {summary_path}") + print(f"已生成 Release Notes: {release_notes_path}") + if warnings: + print("警告:") + for warning in warnings: + print(f"- {warning}") + print( + "指标概览: " + f"Issues={summary['counts']['issues_total']}, " + f"PR={summary['counts']['prs_total']}, " + f"Release={summary['counts']['releases_total']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())