forked from Gitlink/gitlink-cli
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:
parent
e0737c0c95
commit
e431abf8ef
|
|
@ -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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
@ -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' }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
Loading…
Reference in New Issue