diff --git a/.github/MAINTENANCE.md b/.github/MAINTENANCE.md index 7afddff6..b842e7fb 100644 --- a/.github/MAINTENANCE.md +++ b/.github/MAINTENANCE.md @@ -89,13 +89,30 @@ Before ANY commit that adds/modifies skills, run the chain: _Must return 0 errors for new skills._ -2. **Build catalog**: +2. **Enforce the frozen warning budget**: + + ```bash + npm run check:warning-budget + ``` + + This is required before merging or releasing skill changes. It catches new repository-wide warnings, including missing `## When to Use` sections, at PR time instead of letting them surface only during `release:preflight`. + +3. **Check README source credits for changed skills**: + + ```bash + npm run check:readme-credits -- --base origin/main --head HEAD + ``` + + This verifies that changed skills with declared external upstream repos already have the required README credit under `### Official Sources` or `### Community Contributors`. + The first rollout is warning-first for missing structured metadata: if a changed skill clearly looks externally sourced but still lacks `source_repo`, the check warns instead of failing. Once `source_repo` is declared, README coverage is mandatory. + +4. **Build catalog**: ```bash npm run catalog ``` -3. **Optional maintainer sweep shortcut**: +5. **Optional maintainer sweep shortcut**: ```bash npm run sync:repo-state ``` @@ -122,7 +139,7 @@ Before ANY commit that adds/modifies skills, run the chain: ``` `sync:risk-labels` is intentionally conservative. It should handle only the obvious subset; the ambiguous tail still needs maintainer review. -4. **COMMIT GENERATED FILES**: +6. **COMMIT GENERATED FILES**: ```bash git add README.md skills_index.json data/skills_index.json data/catalog.json data/bundles.json data/aliases.json CATALOG.md git commit -m "chore: sync generated files" @@ -138,7 +155,7 @@ Before ANY commit that adds/modifies skills, run the chain: **Before merging:** -1. **CI is green** — Validation, reference checks, tests, and generated artifact steps passed (see [`.github/workflows/ci.yml`](workflows/ci.yml)). If the PR changes any `SKILL.md`, the separate [`skill-review` workflow](workflows/skill-review.yml) must also be green. +1. **CI is green** — Validation, warning-budget enforcement, README source-credit checks, reference checks, tests, and generated artifact steps passed (see [`.github/workflows/ci.yml`](workflows/ci.yml)). If the PR changes any `SKILL.md`, the separate [`skill-review` workflow](workflows/skill-review.yml) must also be green. 2. **Generated drift understood** — On pull requests, generator drift is informational only. Do not block a good PR solely because canonical artifacts would be regenerated. Also do not accept PRs that directly edit `CATALOG.md`, `skills_index.json`, or `data/*.json`; those files are `main`-owned. 3. **Quality Bar** — PR description confirms the [Quality Bar Checklist](.github/PULL_REQUEST_TEMPLATE.md) (metadata, risk label, credits if applicable). 4. **Issue link** — If the PR fixes an issue, the PR description should contain `Closes #N` or `Fixes #N` so GitHub auto-closes the issue on merge. @@ -174,7 +191,7 @@ Use this playbook: gh pr reopen ``` 5. **Approve the newly created fork runs** after reopen. They will usually appear as a fresh pair of `action_required` runs for `Skills Registry CI` and `Skill Review`. -6. **Wait for the new checks only.** You may see older failed `pr-policy` runs in the rollup alongside newer green runs. Merge only after the fresh run set for the current PR state is fully green: `pr-policy`, `source-validation`, `artifact-preview`, and `review` when `SKILL.md` changed. +6. **Wait for the new checks only.** You may see older failed `pr-policy` runs in the rollup alongside newer green runs. Merge only after the fresh run set for the current PR state is fully green: `pr-policy`, `source-validation`, `artifact-preview`, and `review` when `SKILL.md` changed. `source-validation` now enforces the frozen warning budget and README source-credit coverage for changed skills, so missing `## When to Use` sections, missing README repo credits, or other new warning drift must be fixed before merge. 7. **If `gh pr merge` says `Base branch was modified`**, refresh the PR state and retry. This is normal when you are merging a batch and `main` moved between attempts. **If a PR was closed after local integration (reopen and merge):** @@ -218,6 +235,12 @@ We used this flow for PRs [#220](https://github.com/sickn33/antigravity-awesome- ``` 3. **Run the Post-Merge Credits Sync below** — this is mandatory after every PR merge, including single-PR merges. +**Maintainer shortcut for batched PRs:** + +- Use `npm run merge:batch -- --prs 450,449,446,451` to automate the ordered maintainer flow for multiple PRs. +- The script keeps the GitHub-only squash merge rule, handles fork-run approvals and stale PR metadata refresh, waits only on fresh required checks, retries `Base branch was modified`, and runs the mandatory post-merge `sync:contributors` follow-up on `main`. +- It is intentionally not a conflict resolver. If a PR is conflicting, stop and follow the manual conflict playbook. + ### C. Post-Merge Credits Sync (Mandatory After Every PR Merge) This section is **not optional**. Every time a PR is merged, you must ensure both README credit surfaces are correct on `main`: @@ -241,6 +264,7 @@ Do this **immediately after each PR merge**. Do not defer it to release prep. 3. **Audit external-source credits for the merged PR**: - Read the merged PR description, changed files, linked issues, and any release-note draft text you plan to ship. - If the PR added skills, references, or content sourced from an external GitHub repo that is not already credited in `README.md`, add it immediately. + - Treat skill frontmatter `source_repo` + `source_type` as the primary source of truth when present. - If the repo is from an official organization/project source, place it under `### Official Sources`. - If the repo is a non-official ecosystem/community source, place it under `### Community Contributors`. - If the PR reveals that a credited repo is dead, renamed, archived, or overstated, fix the README entry in the same follow-up pass instead of leaving stale metadata behind. @@ -362,6 +386,7 @@ Preflight verification → Changelog → `npm run release:prepare -- X.Y.Z` → npm run release:preflight ``` This now runs the deterministic `sync:release-state` path, refreshes tracked web assets, executes the local test suite, runs the web-app build, and performs `npm pack --dry-run --json` before a release is considered healthy. + If `release:preflight` fails on `check:warning-budget`, treat it as a PR-quality failure and fix the new warnings in source rather than bypassing the gate at release time. If the installer or packaged runtime code changed, you must also verify that new imports are satisfied by `dependencies` rather than `devDependencies`, and ensure the npm-package/runtime tests cover that path. `npm pack --dry-run` alone will not catch missing runtime deps in a clean `npx` environment. Optional diagnostic pass: ```bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ed534ea..592989ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,12 @@ jobs: - name: Validate source changes run: npm run validate + - name: Enforce validation warning budget + run: npm run check:warning-budget + + - name: Verify README source credits for changed skills + run: npm run check:readme-credits -- --base "origin/${{ github.base_ref }}" --head HEAD + - name: Validate references if: needs.pr-policy.outputs.requires_references == 'true' run: npm run validate:references diff --git a/README.md b/README.md index 42b9fda0..174a1009 100644 --- a/README.md +++ b/README.md @@ -524,6 +524,9 @@ We officially thank the following contributors for their help in making this rep - [@jonathimer](https://github.com/jonathimer) - [@Jonohobs](https://github.com/Jonohobs) - [@JaskiratAnand](https://github.com/JaskiratAnand) +- [@jamescha-earley](https://github.com/jamescha-earley) +- [@hazemezz123](https://github.com/hazemezz123) +- [@prewsh](https://github.com/prewsh) ## Star History diff --git a/docs/contributors/skill-anatomy.md b/docs/contributors/skill-anatomy.md index d52fa050..73c3c615 100644 --- a/docs/contributors/skill-anatomy.md +++ b/docs/contributors/skill-anatomy.md @@ -47,6 +47,8 @@ name: my-skill-name description: "Brief description of what this skill does" risk: safe source: community +source_repo: owner/repo +source_type: community --- ``` @@ -81,6 +83,18 @@ source: community - **Examples:** `source: community`, `source: "https://example.com/original"` - **Use `"self"`** if you are the original author +#### `source_repo` +- **What it is:** Canonical GitHub repository identifier for external upstream material +- **Format:** `OWNER/REPO` +- **Example:** `source_repo: Dimillian/Skills` +- **When required:** Use it when the skill adapts or imports material from an external GitHub repository + +#### `source_type` +- **What it is:** Which README credits bucket the upstream repo belongs to +- **Values:** `official` | `community` | `self` +- **Examples:** `source_type: official`, `source_type: community` +- **Rule:** `self` means no external README repo credit is required + ### Optional Fields Some skills include additional metadata: @@ -91,12 +105,22 @@ name: my-skill-name description: "Brief description" risk: safe source: community +source_repo: owner/repo +source_type: community author: "your-name-or-handle" tags: ["react", "typescript", "testing"] tools: [claude, cursor, gemini] --- ``` +### Source-credit contract + +- External GitHub-derived skills should declare both `source_repo` and `source_type`. +- `source_type: official` means the repo must appear in `README.md` under `### Official Sources`. +- `source_type: community` means the repo must appear in `README.md` under `### Community Contributors`. +- `source: self` plus `source_type: self` is the correct shape for original repository content. +- PR CI checks README credit coverage for changed skills, so missing or misbucketed repo credits will block the PR once `source_repo` is declared. + --- ## Part 2: Content diff --git a/docs/contributors/skill-template.md b/docs/contributors/skill-template.md index 6e6c0f13..9837bf08 100644 --- a/docs/contributors/skill-template.md +++ b/docs/contributors/skill-template.md @@ -4,6 +4,8 @@ description: "Brief one-sentence description of what this skill does (under 200 category: your-category risk: safe source: community +source_repo: owner/repo +source_type: community date_added: "YYYY-MM-DD" author: your-name-or-handle tags: [tag-one, tag-two] @@ -17,6 +19,13 @@ tools: [claude, cursor, gemini] A brief explanation of what this skill does and why it exists. 2-4 sentences is perfect. +If this skill adapts material from an external GitHub repository, declare both: + +- `source_repo: owner/repo` +- `source_type: official` or `source_type: community` + +Use `source: self` and `source_type: self` when the skill is original to this repository and does not require README external-source credit. + ## When to Use This Skill - Use when you need to [scenario 1] diff --git a/package.json b/package.json index b508f56c..ddddb639 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,13 @@ "build": "npm run chain && npm run catalog", "check:stale-claims": "node tools/scripts/run-python.js tools/scripts/check_stale_claims.py", "check:warning-budget": "node tools/scripts/run-python.js tools/scripts/check_validation_warning_budget.py", + "check:readme-credits": "node tools/scripts/run-python.js tools/scripts/check_readme_credits.py", "audit:consistency": "node tools/scripts/run-python.js tools/scripts/audit_consistency.py", "audit:consistency:github": "node tools/scripts/run-python.js tools/scripts/audit_consistency.py --check-github-about", "audit:maintainer": "node tools/scripts/run-python.js tools/scripts/maintainer_audit.py", "security:docs": "node tools/scripts/tests/docs_security_content.test.js", "pr:preflight": "node tools/scripts/pr_preflight.cjs", + "merge:batch": "node tools/scripts/merge_batch.cjs", "release:preflight": "node tools/scripts/release_workflow.js preflight", "release:prepare": "node tools/scripts/release_workflow.js prepare", "release:publish": "node tools/scripts/release_workflow.js publish", diff --git a/tools/scripts/check_readme_credits.py b/tools/scripts/check_readme_credits.py new file mode 100644 index 00000000..e9655aac --- /dev/null +++ b/tools/scripts/check_readme_credits.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections.abc import Mapping +from datetime import date, datetime +from pathlib import Path + +import yaml + +from _project_paths import find_repo_root + + +GITHUB_REPO_PATTERN = re.compile(r"https://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)") +SOURCE_REPO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +VALID_SOURCE_TYPES = {"official", "community", "self"} + + +def normalize_yaml_value(value): + if isinstance(value, Mapping): + return {key: normalize_yaml_value(val) for key, val in value.items()} + if isinstance(value, list): + return [normalize_yaml_value(item) for item in value] + if isinstance(value, (date, datetime)): + return value.isoformat() + return value + + +def parse_frontmatter(content: str) -> dict[str, object]: + match = re.search(r"^---\s*\n(.*?)\n?---(?:\s*\n|$)", content, re.DOTALL) + if not match: + return {} + + try: + parsed = yaml.safe_load(match.group(1)) or {} + except yaml.YAMLError: + return {} + + parsed = normalize_yaml_value(parsed) + if not isinstance(parsed, Mapping): + return {} + return dict(parsed) + + +def normalize_repo_slug(value: str | None) -> str | None: + if not isinstance(value, str): + return None + + candidate = value.strip().strip('"').strip("'") + if candidate.startswith("https://github.com/"): + candidate = candidate[len("https://github.com/") :] + candidate = candidate.rstrip("/") + candidate = candidate.removesuffix(".git") + candidate = candidate.split("#", 1)[0] + candidate = candidate.split("?", 1)[0] + + match = re.match(r"^([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)", candidate) + if not match: + return None + return match.group(1).lower() + + +def run_git(args: list[str], cwd: str | Path, capture: bool = True) -> str: + result = subprocess.run( + ["git", *args], + cwd=str(cwd), + check=False, + capture_output=capture, + text=True, + ) + if result.returncode != 0: + stderr = result.stderr.strip() if capture and result.stderr else "" + raise RuntimeError(stderr or f"git {' '.join(args)} failed with exit code {result.returncode}") + return result.stdout.strip() if capture else "" + + +def get_changed_files(base_dir: str | Path, base_ref: str, head_ref: str) -> list[str]: + output = run_git(["diff", "--name-only", f"{base_ref}...{head_ref}"], cwd=base_dir) + files = [] + seen = set() + for raw_line in output.splitlines(): + normalized = raw_line.replace("\\", "/").strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + files.append(normalized) + return files + + +def is_skill_file(file_path: str) -> bool: + normalized = file_path.replace("\\", "/") + return normalized.startswith("skills/") and normalized.endswith("/SKILL.md") + + +def extract_credit_repos(readme_text: str) -> dict[str, set[str]]: + credits = {"official": set(), "community": set()} + current_section: str | None = None + + for line in readme_text.splitlines(): + heading = re.match(r"^(#{2,6})\s+(.*)$", line.strip()) + if heading: + title = heading.group(2).strip() + if title == "Official Sources": + current_section = "official" + continue + if title == "Community Contributors": + current_section = "community" + continue + current_section = None + continue + + if current_section is None: + continue + + for repo_match in GITHUB_REPO_PATTERN.finditer(line): + credits[current_section].add(repo_match.group(1).lower()) + + return credits + + +def classify_source(metadata: dict[str, object]) -> str | None: + raw_source_type = metadata.get("source_type") + if isinstance(raw_source_type, str) and raw_source_type.strip(): + source_type = raw_source_type.strip().lower() + return source_type if source_type in VALID_SOURCE_TYPES else None + + raw_source = metadata.get("source") + if isinstance(raw_source, str) and raw_source.strip().lower() == "self": + return "self" + + if metadata.get("source_repo"): + return "community" + + return None + + +def collect_reports(base_dir: str | Path, base_ref: str, head_ref: str) -> dict[str, object]: + root = Path(base_dir) + changed_files = get_changed_files(root, base_ref, head_ref) + skill_files = [file_path for file_path in changed_files if is_skill_file(file_path)] + readme_path = root / "README.md" + readme_text = readme_path.read_text(encoding="utf-8") + readme_credit_sets = extract_credit_repos(readme_text) + + warnings: list[str] = [] + errors: list[str] = [] + checked_skills: list[dict[str, object]] = [] + + for rel_path in skill_files: + skill_path = root / rel_path + content = skill_path.read_text(encoding="utf-8") + metadata = parse_frontmatter(content) + + source_type = classify_source(metadata) + raw_source_repo = metadata.get("source_repo") + source_repo = normalize_repo_slug(raw_source_repo) + source_value = metadata.get("source") + + checked_skills.append( + { + "path": rel_path, + "source": source_value, + "source_type": source_type, + "source_repo": source_repo, + } + ) + + if source_type is None and metadata.get("source_type") is not None: + errors.append(f"{rel_path}: invalid source_type {metadata.get('source_type')!r}") + continue + + if raw_source_repo is not None and source_repo is None: + errors.append(f"{rel_path}: invalid source_repo {raw_source_repo!r}; expected OWNER/REPO") + continue + + if source_type == "self": + continue + + if source_repo is None: + if isinstance(source_value, str) and source_value.strip().lower() != "self": + warnings.append( + f"{rel_path}: external source declared without source_repo; README credit check skipped" + ) + continue + + if not SOURCE_REPO_PATTERN.match(source_repo): + errors.append(f"{rel_path}: invalid source_repo {source_repo!r}; expected OWNER/REPO") + continue + + bucket = "official" if source_type == "official" else "community" + if source_repo not in readme_credit_sets[bucket]: + location_hint = "### Official Sources" if bucket == "official" else "### Community Contributors" + errors.append( + f"{rel_path}: source_repo {source_repo} is missing from {location_hint} in README.md" + ) + + # If the source repo only exists in the wrong bucket, keep the failure focused on the missing + # required attribution instead of reporting duplicate noise. + + return { + "changed_files": changed_files, + "skill_files": skill_files, + "checked_skills": checked_skills, + "warnings": warnings, + "errors": errors, + "readme_credits": { + bucket: sorted(repos) + for bucket, repos in readme_credit_sets.items() + }, + } + + +def check_readme_credits(base_dir: str | Path, base_ref: str, head_ref: str) -> dict[str, object]: + return collect_reports(base_dir, base_ref, head_ref) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate README credits for changed skills.") + parser.add_argument("--base", default="origin/main", help="Base ref for git diff (default: origin/main)") + parser.add_argument("--head", default="HEAD", help="Head ref for git diff (default: HEAD)") + parser.add_argument("--json", action="store_true", help="Print the report as JSON.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + root = find_repo_root(__file__) + report = check_readme_credits(root, args.base, args.head) + + if args.json: + print(json.dumps(report, indent=2)) + else: + if report["skill_files"]: + print(f"[readme-credits] Changed skill files: {len(report['skill_files'])}") + else: + print("[readme-credits] No changed skill files detected.") + + for warning in report["warnings"]: + print(f"⚠️ {warning}") + for error in report["errors"]: + print(f"❌ {error}") + + return 0 if not report["errors"] else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except RuntimeError as exc: + print(f"❌ {exc}", file=sys.stderr) + sys.exit(1) diff --git a/tools/scripts/merge_batch.cjs b/tools/scripts/merge_batch.cjs new file mode 100644 index 00000000..846e3441 --- /dev/null +++ b/tools/scripts/merge_batch.cjs @@ -0,0 +1,646 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const { findProjectRoot } = require("../lib/project-root"); +const { + hasQualityChecklist, + normalizeRepoPath, +} = require("../lib/workflow-contract"); + +const REOPEN_COMMENT = + "Maintainer workflow refresh: closing and reopening to retrigger pull_request checks against the updated PR body."; +const DEFAULT_POLL_SECONDS = 20; +const BASE_BRANCH_MODIFIED_PATTERNS = [ + /base branch was modified/i, + /base branch has been modified/i, + /branch was modified/i, +]; +const REQUIRED_CHECKS = [ + ["pr-policy", ["pr-policy"]], + ["source-validation", ["source-validation"]], + ["artifact-preview", ["artifact-preview"]], +]; +const SKILL_REVIEW_REQUIRED = ["review", "Skill Review & Optimize", "Skill Review & Optimize / review"]; + +function parseArgs(argv) { + const args = { + prs: null, + pollSeconds: DEFAULT_POLL_SECONDS, + dryRun: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--prs") { + args.prs = argv[index + 1] || null; + index += 1; + } else if (arg === "--poll-seconds") { + args.pollSeconds = Number(argv[index + 1]); + index += 1; + } else if (arg === "--dry-run") { + args.dryRun = true; + } + } + + if (typeof args.pollSeconds !== "number" || Number.isNaN(args.pollSeconds) || args.pollSeconds <= 0) { + args.pollSeconds = DEFAULT_POLL_SECONDS; + } + + return args; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function readRepositorySlug(projectRoot) { + const packageJson = readJson(path.join(projectRoot, "package.json")); + const repository = packageJson.repository; + const rawUrl = + typeof repository === "string" + ? repository + : repository && typeof repository.url === "string" + ? repository.url + : null; + + if (!rawUrl) { + throw new Error("package.json repository.url is required to resolve the GitHub slug."); + } + + const match = rawUrl.match(/github\.com[:/](?[^/]+\/[^/]+?)(?:\.git)?$/i); + if (!match?.groups?.slug) { + throw new Error(`Could not derive a GitHub repo slug from repository url: ${rawUrl}`); + } + + return match.groups.slug; +} + +function runCommand(command, args, cwd, options = {}) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + input: options.input, + stdio: options.capture + ? ["pipe", "pipe", "pipe"] + : options.input !== undefined + ? ["pipe", "inherit", "inherit"] + : ["inherit", "inherit", "inherit"], + shell: process.platform === "win32", + }); + + if (result.error) { + throw result.error; + } + + if (typeof result.status !== "number" || result.status !== 0) { + const stderr = options.capture ? result.stderr.trim() : ""; + throw new Error(stderr || `${command} ${args.join(" ")} failed with status ${result.status}`); + } + + return options.capture ? result.stdout.trim() : ""; +} + +function runGhJson(projectRoot, args, options = {}) { + const stdout = runCommand( + "gh", + [...args, "--json", options.jsonFields || ""].filter(Boolean), + projectRoot, + { capture: true, input: options.input }, + ); + return JSON.parse(stdout || "null"); +} + +function runGhApiJson(projectRoot, args, options = {}) { + const ghArgs = ["api", ...args]; + if (options.paginate) { + ghArgs.push("--paginate"); + } + if (options.slurp) { + ghArgs.push("--slurp"); + } + const stdout = runCommand("gh", ghArgs, projectRoot, { capture: true, input: options.input }); + return JSON.parse(stdout || "null"); +} + +function flattenGhSlurpPayload(payload) { + if (!Array.isArray(payload)) { + return []; + } + + const flattened = []; + for (const page of payload) { + if (Array.isArray(page)) { + flattened.push(...page); + } else if (page && typeof page === "object") { + flattened.push(page); + } + } + return flattened; +} + +function ensureOnMainAndClean(projectRoot) { + const branch = runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], projectRoot, { + capture: true, + }); + if (branch !== "main") { + throw new Error(`merge-batch must run from main. Current branch: ${branch}`); + } + + const status = runCommand( + "git", + ["status", "--porcelain", "--untracked-files=no"], + projectRoot, + { capture: true }, + ); + if (status) { + throw new Error("merge-batch requires a clean tracked working tree before starting."); + } +} + +function parsePrList(prs) { + if (!prs) { + throw new Error("Usage: merge_batch.cjs --prs 450,449,446,451"); + } + + const parsed = prs + .split(/[\s,]+/) + .map((value) => Number.parseInt(value, 10)) + .filter((value) => Number.isInteger(value) && value > 0); + + if (!parsed.length) { + throw new Error("No valid PR numbers were provided."); + } + + return [...new Set(parsed)]; +} + +function extractSummaryBlock(body) { + const text = String(body || "").replace(/\r\n/g, "\n").trim(); + if (!text) { + return ""; + } + + const sectionMatch = text.match(/^\s*##\s+/m); + if (!sectionMatch) { + return text; + } + + const prefix = text.slice(0, sectionMatch.index).trimEnd(); + return prefix; +} + +function extractTemplateSections(templateContent) { + const text = String(templateContent || "").replace(/\r\n/g, "\n").trim(); + const sectionMatch = text.match(/^\s*##\s+/m); + if (!sectionMatch) { + return text; + } + + return text.slice(sectionMatch.index).trim(); +} + +function normalizePrBody(body, templateContent) { + const summary = extractSummaryBlock(body); + const templateSections = extractTemplateSections(templateContent); + + if (!summary) { + return templateSections; + } + + return `${summary}\n\n${templateSections}`.trim(); +} + +function loadPullRequestTemplate(projectRoot) { + return fs.readFileSync(path.join(projectRoot, ".github", "PULL_REQUEST_TEMPLATE.md"), "utf8"); +} + +function loadPullRequestDetails(projectRoot, repoSlug, prNumber) { + const details = runGhJson(projectRoot, ["pr", "view", String(prNumber)], { + jsonFields: [ + "body", + "mergeStateStatus", + "mergeable", + "number", + "title", + "headRefOid", + "url", + ].join(","), + }); + + const filesPayload = runGhApiJson(projectRoot, [ + `repos/${repoSlug}/pulls/${prNumber}/files?per_page=100`, + ], { + paginate: true, + slurp: true, + }); + + const files = flattenGhSlurpPayload(filesPayload) + .map((entry) => normalizeRepoPath(entry?.filename)) + .filter(Boolean); + + return { + ...details, + files, + hasSkillChanges: files.some((filePath) => filePath.endsWith("/SKILL.md") || filePath === "SKILL.md"), + }; +} + +function needsBodyRefresh(prDetails) { + return !hasQualityChecklist(prDetails.body); +} + +function getRequiredCheckAliases(prDetails) { + const aliases = REQUIRED_CHECKS.map(([, value]) => value); + if (prDetails.hasSkillChanges) { + aliases.push(SKILL_REVIEW_REQUIRED); + } + return aliases; +} + +function mergeableIsConflict(prDetails) { + const mergeable = String(prDetails.mergeable || "").toUpperCase(); + const mergeState = String(prDetails.mergeStateStatus || "").toUpperCase(); + return mergeable === "CONFLICTING" || mergeState === "DIRTY"; +} + +function selectLatestCheckRuns(checkRuns) { + const byName = new Map(); + + for (const run of checkRuns) { + const name = String(run?.name || ""); + if (!name) { + continue; + } + + const previous = byName.get(name); + if (!previous) { + byName.set(name, run); + continue; + } + + const currentKey = run.completed_at || run.started_at || run.created_at || ""; + const previousKey = previous.completed_at || previous.started_at || previous.created_at || ""; + + if (currentKey > previousKey || (currentKey === previousKey && Number(run.id || 0) > Number(previous.id || 0))) { + byName.set(name, run); + } + } + + return byName; +} + +function checkRunMatchesAliases(checkRun, aliases) { + const name = String(checkRun?.name || ""); + return aliases.some((alias) => name === alias || name.endsWith(` / ${alias}`)); +} + +function summarizeRequiredCheckRuns(checkRuns, requiredAliases) { + const latestByName = selectLatestCheckRuns(checkRuns); + const summaries = []; + + for (const aliases of requiredAliases) { + const latestRun = [...latestByName.values()].find((run) => checkRunMatchesAliases(run, aliases)); + const label = aliases[0]; + + if (!latestRun) { + summaries.push({ label, state: "missing", conclusion: null, run: null }); + continue; + } + + const status = String(latestRun.status || "").toLowerCase(); + const conclusion = String(latestRun.conclusion || "").toLowerCase(); + if (status !== "completed") { + summaries.push({ label, state: "pending", conclusion, run: latestRun }); + continue; + } + + if (["success", "neutral", "skipped"].includes(conclusion)) { + summaries.push({ label, state: "success", conclusion, run: latestRun }); + continue; + } + + summaries.push({ label, state: "failed", conclusion, run: latestRun }); + } + + return summaries; +} + +function formatCheckSummary(summaries) { + return summaries + .map((summary) => { + if (summary.state === "success") { + return `${summary.label}: ${summary.conclusion || "success"}`; + } + if (summary.state === "pending") { + return `${summary.label}: pending (${summary.conclusion || "in progress"})`; + } + if (summary.state === "failed") { + return `${summary.label}: failed (${summary.conclusion || "unknown"})`; + } + return `${summary.label}: missing`; + }) + .join(", "); +} + +function getHeadSha(projectRoot, repoSlug, prNumber) { + const details = runGhJson(projectRoot, ["pr", "view", String(prNumber)], { + jsonFields: "headRefOid", + }); + return details.headRefOid; +} + +function listActionRequiredRuns(projectRoot, repoSlug, headSha) { + const payload = runGhApiJson(projectRoot, [ + `repos/${repoSlug}/actions/runs?head_sha=${headSha}&status=action_required&per_page=100`, + ], { + paginate: true, + slurp: true, + }); + + const runs = flattenGhSlurpPayload(payload).filter((run) => Number.isInteger(Number(run?.id))); + const seen = new Set(); + return runs.filter((run) => { + const id = Number(run.id); + if (seen.has(id)) { + return false; + } + seen.add(id); + return true; + }); +} + +function approveActionRequiredRuns(projectRoot, repoSlug, headSha) { + const runs = listActionRequiredRuns(projectRoot, repoSlug, headSha); + for (const run of runs) { + runCommand( + "gh", + ["api", "-X", "POST", `repos/${repoSlug}/actions/runs/${run.id}/approve`], + projectRoot, + ); + } + return runs; +} + +function listCheckRuns(projectRoot, repoSlug, headSha) { + const payload = runGhApiJson(projectRoot, [ + `repos/${repoSlug}/commits/${headSha}/check-runs?per_page=100`, + ]); + return Array.isArray(payload?.check_runs) ? payload.check_runs : []; +} + +async function waitForRequiredChecks( + projectRoot, + repoSlug, + headSha, + requiredAliases, + pollSeconds, + maxAttempts = 180, +) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const checkRuns = listCheckRuns(projectRoot, repoSlug, headSha); + const summaries = summarizeRequiredCheckRuns(checkRuns, requiredAliases); + const pending = summaries.filter((summary) => summary.state === "pending" || summary.state === "missing"); + const failed = summaries.filter((summary) => summary.state === "failed"); + + console.log(`[merge-batch] Checks for ${headSha}: ${formatCheckSummary(summaries)}`); + + if (failed.length) { + throw new Error( + `Required checks failed for ${headSha}: ${failed.map((item) => `${item.label} (${item.conclusion || "failed"})`).join(", ")}`, + ); + } + + if (!pending.length) { + return summaries; + } + + await new Promise((resolve) => setTimeout(resolve, pollSeconds * 1000)); + } + + throw new Error(`Timed out waiting for required checks on ${headSha}.`); +} + +function patchPrBody(projectRoot, repoSlug, prNumber, body) { + const payload = JSON.stringify({ body }); + runCommand( + "gh", + ["api", `repos/${repoSlug}/pulls/${prNumber}`, "-X", "PATCH", "--input", "-"], + projectRoot, + { input: payload }, + ); +} + +function closeAndReopenPr(projectRoot, prNumber) { + runCommand("gh", ["pr", "close", String(prNumber), "--comment", REOPEN_COMMENT], projectRoot); + runCommand("gh", ["pr", "reopen", String(prNumber)], projectRoot); +} + +function isRetryableMergeError(error) { + const message = String(error?.message || error || ""); + return BASE_BRANCH_MODIFIED_PATTERNS.some((pattern) => pattern.test(message)); +} + +function gitCheckoutMain(projectRoot) { + runCommand("git", ["checkout", "main"], projectRoot); +} + +function gitPullMain(projectRoot) { + runCommand("git", ["pull", "--ff-only", "origin", "main"], projectRoot); +} + +function syncContributors(projectRoot) { + runCommand("npm", ["run", "sync:contributors"], projectRoot); +} + +function commitAndPushReadmeIfChanged(projectRoot) { + const status = runCommand("git", ["status", "--porcelain", "--untracked-files=no"], projectRoot, { + capture: true, + }); + + if (!status) { + return { changed: false }; + } + + const lines = status.split(/\r?\n/).filter(Boolean); + const unexpected = lines.filter((line) => !line.includes("README.md")); + if (unexpected.length) { + throw new Error(`merge-batch expected sync:contributors to touch README.md only. Unexpected drift: ${unexpected.join(", ")}`); + } + + runCommand("git", ["add", "README.md"], projectRoot); + const staged = runCommand("git", ["diff", "--cached", "--name-only"], projectRoot, { capture: true }); + if (!staged.includes("README.md")) { + return { changed: false }; + } + + runCommand("git", ["commit", "-m", "chore: sync contributor credits after merge batch"], projectRoot); + runCommand("git", ["push", "origin", "main"], projectRoot); + return { changed: true }; +} + +async function mergePullRequest(projectRoot, repoSlug, prNumber, options) { + const template = loadPullRequestTemplate(projectRoot); + let prDetails = loadPullRequestDetails(projectRoot, repoSlug, prNumber); + + console.log(`[merge-batch] PR #${prNumber}: ${prDetails.title}`); + + if (mergeableIsConflict(prDetails)) { + throw new Error(`PR #${prNumber} is in conflict state; resolve conflicts on the PR branch before merging.`); + } + + let bodyRefreshed = false; + if (needsBodyRefresh(prDetails)) { + const normalizedBody = normalizePrBody(prDetails.body, template); + if (!options.dryRun) { + patchPrBody(projectRoot, repoSlug, prNumber, normalizedBody); + closeAndReopenPr(projectRoot, prNumber); + } + bodyRefreshed = true; + console.log(`[merge-batch] PR #${prNumber}: refreshed PR body and retriggered checks.`); + prDetails = loadPullRequestDetails(projectRoot, repoSlug, prNumber); + } + + const headSha = getHeadSha(projectRoot, repoSlug, prNumber); + const approvedRuns = options.dryRun ? [] : approveActionRequiredRuns(projectRoot, repoSlug, headSha); + if (approvedRuns.length) { + console.log( + `[merge-batch] PR #${prNumber}: approved ${approvedRuns.length} fork run(s) waiting on action_required.`, + ); + } + + const requiredCheckAliases = getRequiredCheckAliases(prDetails); + if (!options.dryRun) { + await waitForRequiredChecks(projectRoot, repoSlug, headSha, requiredCheckAliases, options.pollSeconds); + } + + if (options.dryRun) { + console.log(`[merge-batch] PR #${prNumber}: dry run complete, skipping merge and post-merge sync.`); + return { + prNumber, + bodyRefreshed, + merged: false, + approvedRuns: [], + followUp: { changed: false }, + }; + } + + let merged = false; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + if (!options.dryRun) { + runCommand("gh", ["pr", "merge", String(prNumber), "--squash"], projectRoot); + } + merged = true; + break; + } catch (error) { + if (!isRetryableMergeError(error) || attempt === 3) { + throw error; + } + + console.log(`[merge-batch] PR #${prNumber}: base branch changed, refreshing main and retrying merge.`); + gitCheckoutMain(projectRoot); + gitPullMain(projectRoot); + prDetails = loadPullRequestDetails(projectRoot, repoSlug, prNumber); + const refreshedSha = prDetails.headRefOid || headSha; + if (!options.dryRun) { + await waitForRequiredChecks(projectRoot, repoSlug, refreshedSha, requiredCheckAliases, options.pollSeconds); + } + } + } + + if (!merged) { + throw new Error(`Failed to merge PR #${prNumber}.`); + } + + console.log(`[merge-batch] PR #${prNumber}: merged.`); + + gitCheckoutMain(projectRoot); + gitPullMain(projectRoot); + syncContributors(projectRoot); + + const followUp = commitAndPushReadmeIfChanged(projectRoot); + if (followUp.changed) { + console.log(`[merge-batch] PR #${prNumber}: README follow-up committed and pushed.`); + } + + return { + prNumber, + bodyRefreshed, + merged, + approvedRuns: approvedRuns.map((run) => run.id), + followUp, + }; +} + +async function runBatch(projectRoot, prNumbers, options = {}) { + const repoSlug = readRepositorySlug(projectRoot); + const results = []; + + ensureOnMainAndClean(projectRoot); + + for (const prNumber of prNumbers) { + const result = await mergePullRequest(projectRoot, repoSlug, prNumber, options); + results.push(result); + } + + return results; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const projectRoot = findProjectRoot(__dirname); + const prNumbers = parsePrList(args.prs); + + if (args.dryRun) { + console.log(`[merge-batch] Dry run for PRs: ${prNumbers.join(", ")}`); + } + + const results = await runBatch(projectRoot, prNumbers, { + dryRun: args.dryRun, + pollSeconds: args.pollSeconds, + }); + + console.log( + `[merge-batch] Completed ${results.length} PR(s): ${results.map((result) => `#${result.prNumber}`).join(", ")}`, + ); +} + +if (require.main === module) { + main().catch((error) => { + console.error(`[merge-batch] ${error.message}`); + process.exit(1); + }); +} + +module.exports = { + approveActionRequiredRuns, + baseBranchModifiedPatterns: BASE_BRANCH_MODIFIED_PATTERNS, + checkRunMatchesAliases, + closeAndReopenPr, + commitAndPushReadmeIfChanged, + ensureOnMainAndClean, + extractSummaryBlock, + extractTemplateSections, + formatCheckSummary, + getRequiredCheckAliases, + gitCheckoutMain, + gitPullMain, + isRetryableMergeError, + listActionRequiredRuns, + listCheckRuns, + loadPullRequestDetails, + loadPullRequestTemplate, + mergePullRequest, + mergeableIsConflict, + normalizePrBody, + parseArgs, + parsePrList, + readRepositorySlug, + runBatch, + selectLatestCheckRuns, + summarizeRequiredCheckRuns, + waitForRequiredChecks, +}; diff --git a/tools/scripts/sync_contributors.py b/tools/scripts/sync_contributors.py index d920e093..bfddc418 100644 --- a/tools/scripts/sync_contributors.py +++ b/tools/scripts/sync_contributors.py @@ -31,6 +31,22 @@ def parse_existing_contributor_links(content: str) -> dict[str, str]: return links +def parse_existing_contributor_order(content: str) -> list[str]: + order: list[str] = [] + seen: set[str] = set() + pattern = re.compile(r"^- \[@(?P