chore(sweep): add workflow runtime deps + test repo scripts

- 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
This commit is contained in:
wauxing 2026-07-07 19:24:23 +08:00
parent e0737c0c95
commit e431abf8ef
4 changed files with 1798 additions and 2 deletions

521
scripts/import-test-data.sh Executable file
View File

@ -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 "$@"

View File

@ -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

View File

@ -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' }
)

View File

@ -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())