From b9a420e71bdc72e6fecafac1a6480448219d40cc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 6 May 2026 23:34:39 +0100 Subject: [PATCH] feat: add generic openclaw target onboarding --- CHANGELOG.md | 3 + config/target-repositories.json | 46 +++++ docs/scheduler.md | 11 ++ docs/target-dispatcher.md | 7 + docs/target-repositories.md | 64 +++++++ src/repository-profiles.ts | 294 +++++++++++++++++++++++++------ test/repository-profiles.test.ts | 26 +++ 7 files changed, 394 insertions(+), 57 deletions(-) create mode 100644 config/target-repositories.json create mode 100644 docs/target-repositories.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0faa3ade1c..5e53136585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,9 @@ checkpoint, and status-only commits are intentionally omitted. - Scoped sweep record/status publishing to the active target repository slug so concurrent runs for other repositories cannot overwrite newly added target records from stale generated state. +- Added data-driven target repository config plus a conservative `openclaw/*` + fallback so newly installed OpenClaw repositories can use exact event review + without a TypeScript profile change. - Reduced default worker fan-out by about 20% across review shards, hot intake, commit review pages, repair live-worker caps, and automatic implementation dispatches. diff --git a/config/target-repositories.json b/config/target-repositories.json new file mode 100644 index 0000000000..9350185bce --- /dev/null +++ b/config/target-repositories.json @@ -0,0 +1,46 @@ +{ + "schema_version": 1, + "repositories": [ + { + "target_repo": "openclaw/clawhub", + "display_name": "ClawHub", + "checkout_dir": "clawhub", + "community_url": "https://clawhub.ai/", + "prompt_note": "Use the ClawHub source tree and current main branch. Review every issue and PR with the same evidence standard, but only propose auto-close for pull requests that are certainly implemented on main. Keep everything else open.", + "apply_close_rules": { + "issue": [], + "pull_request": ["implemented_on_main"] + } + }, + { + "target_repo": "openclaw/clawsweeper", + "display_name": "ClawSweeper", + "checkout_dir": "clawsweeper", + "prompt_note": "Use the ClawSweeper source tree and current main branch. Review bot automation, workflow, and documentation changes conservatively. Only propose auto-close for pull requests that are certainly implemented on main; keep issues open for maintainer triage.", + "apply_close_rules": { + "issue": [], + "pull_request": ["implemented_on_main"] + } + }, + { + "target_repo": "openclaw/fs-safe", + "display_name": "fs-safe", + "checkout_dir": "fs-safe", + "prompt_note": "Use the fs-safe source tree and current main branch. Review filesystem-safety, path-handling, and package changes conservatively. Only propose auto-close for pull requests that are certainly implemented on main; keep issues open for maintainer triage.", + "apply_close_rules": { + "issue": [], + "pull_request": ["implemented_on_main"] + } + } + ], + "openclaw_fallback": { + "owner": "openclaw", + "deny_repositories": ["openclaw/clawsweeper-state", "openclaw/.github"], + "allow_repo_name_pattern": "^[A-Za-z0-9_.-]+$", + "prompt_note": "Use the {target_repo} source tree and current main branch. This repository is using the generic OpenClaw onboarding profile, so keep review conservative: issues are review/comment-only, and pull requests may be auto-closed only when the exact change is certainly already implemented on main.", + "apply_close_rules": { + "issue": [], + "pull_request": ["implemented_on_main"] + } + } +} diff --git a/docs/scheduler.md b/docs/scheduler.md index 4076f837aa..62c6cb5c57 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -31,6 +31,9 @@ Important source files: - `src/clawsweeper.ts`: item selection, cadence, planning, review, dashboard, and status JSON +- `config/target-repositories.json`: configured non-core target repositories + and the conservative `openclaw/*` exact-review fallback +- `docs/target-repositories.md`: target onboarding and rollout checklist - `src/repair/workflow-utils.ts`: GitHub Actions output shaping for plans - `results/sweep-status/.json`: generated state consumed by the dashboard @@ -75,6 +78,14 @@ group, so targeted maintainer checks do not wait behind broad normal backfill. - issues are review/comment-only; PRs may auto-close only when already implemented on `main` +Other `openclaw/*` repositories: + +- exact event/manual review: supported through the generic conservative + fallback after the target dispatcher and GitHub App installation are present +- scheduled review/apply/audit: not enabled automatically +- issues are review/comment-only; PRs may auto-close only when already + implemented on `main` + Manual `workflow_dispatch` can override `target_repo`, `item_number`, `item_numbers`, `batch_size`, `shard_count`, `hot_intake`, and apply inputs. Exact item dispatches use a dedicated concurrency group and exact planner diff --git a/docs/target-dispatcher.md b/docs/target-dispatcher.md index 1ed51aca31..37c498b941 100644 --- a/docs/target-dispatcher.md +++ b/docs/target-dispatcher.md @@ -22,6 +22,13 @@ For issue and PR dispatch, copy this workflow into each target repository as `.github/workflows/clawsweeper-dispatch.yml`, or merge these triggers and the `Dispatch exact ClawSweeper review` step into an existing combined dispatcher: +Target repositories no longer need a TypeScript profile before exact event +review can run. Any installed `openclaw/*` repository that is not denied in +`config/target-repositories.json` uses the conservative generic profile: +issues stay open, and PRs can auto-close only when already implemented on +`main`. Add a config entry only when the repo should appear in the dashboard or +needs repo-specific review guidance. + ```yaml name: ClawSweeper Dispatch diff --git a/docs/target-repositories.md b/docs/target-repositories.md new file mode 100644 index 0000000000..91dec674ad --- /dev/null +++ b/docs/target-repositories.md @@ -0,0 +1,64 @@ +# Target Repositories + +Read when enabling ClawSweeper for another OpenClaw repository, changing +`config/target-repositories.json`, or debugging `Unsupported target repo` +failures. + +ClawSweeper has two target-repository paths: + +- configured dashboard targets in `config/target-repositories.json` +- a conservative generic fallback for exact event/manual reviews of + `openclaw/*` repositories + +`openclaw/openclaw` remains a built-in profile because it has broader +auto-close policy. Other configured targets default to safer repo-local rules: +issues are review/comment-only, and PRs may auto-close only when the same +change is certainly already implemented on `main`. + +## Generic OpenClaw Fallback + +The fallback lets a newly installed OpenClaw repo dispatch to ClawSweeper +without a TypeScript change. It is intentionally narrow: + +- owner must be `openclaw` +- repo name must match `allow_repo_name_pattern` +- denied repositories are rejected +- issues cannot be auto-closed +- PRs can auto-close only for `implemented_on_main` +- scheduled dashboard/backfill rows are not added automatically + +This is enough for event-driven review after the target repo has the dispatcher +workflow and GitHub App installation. It is not a blanket scheduled rollout. + +## Add One Repository + +1. Install the ClawSweeper GitHub App on the target repository. +2. Add or merge the target dispatcher from + [`docs/target-dispatcher.md`](target-dispatcher.md). +3. Ensure the target repo can read the org or repo + `CLAWSWEEPER_APP_PRIVATE_KEY` secret. +4. Open, edit, or comment on a target issue/PR and confirm a dispatcher run + appears in the target repo. +5. Confirm the receiver run appears in + `https://github.com/openclaw/clawsweeper/actions`. +6. Confirm the target item gets one durable ClawSweeper review comment. + +For a repo that should appear in the README dashboard or scheduled queues, add +it to `config/target-repositories.json` with an explicit prompt note and +close-policy block. Keep the default policy unless the repo has a documented +reason to allow broader issue closes. + +## Add Many Repositories + +Batch rollout should be incremental: + +- install the app and dispatcher on a small group first +- leave scheduled backfill off +- verify event review/comment sync on one issue or PR per repo +- add config entries for repos that should show in the dashboard +- enable scheduled backfill/apply only after repo-specific safety rules exist + +If a target dispatch reaches ClawSweeper but receiver token creation fails, the +App is usually not installed on that target repo. If the target workflow skips +before dispatch, the target repo usually cannot access +`CLAWSWEEPER_APP_PRIVATE_KEY`. diff --git a/src/repository-profiles.ts b/src/repository-profiles.ts index 3e33a23310..e15e6b1f5b 100644 --- a/src/repository-profiles.ts +++ b/src/repository-profiles.ts @@ -1,3 +1,7 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + export type RepositoryItemKind = "issue" | "pull_request"; export type RepositoryCloseReason = | "implemented_on_main" @@ -20,6 +24,30 @@ export interface RepositoryProfile { applyCloseRules: Partial>; } +interface TargetRepositoryConfig { + schemaVersion: 1; + repositories: readonly ConfiguredRepositoryProfile[]; + openclawFallback?: OpenClawFallbackConfig; +} + +interface ConfiguredRepositoryProfile { + targetRepo: string; + displayName: string; + checkoutDir: string; + docsUrl?: string; + communityUrl?: string; + promptNote: string; + applyCloseRules: Partial>; +} + +interface OpenClawFallbackConfig { + owner: string; + denyRepositories: readonly string[]; + allowRepoNamePattern: RegExp; + promptNote: string; + applyCloseRules: Partial>; +} + const OPENCLAW_CLOSE_REASONS: readonly RepositoryCloseReason[] = [ "implemented_on_main", "cannot_reproduce", @@ -30,60 +58,32 @@ const OPENCLAW_CLOSE_REASONS: readonly RepositoryCloseReason[] = [ "stale_insufficient_info", ]; +const ALL_CLOSE_REASONS: readonly RepositoryCloseReason[] = [...OPENCLAW_CLOSE_REASONS, "none"]; +const CLOSE_REASON_SET = new Set(ALL_CLOSE_REASONS); +const ITEM_KIND_SET = new Set(["issue", "pull_request"]); + export const DEFAULT_TARGET_REPO = "openclaw/openclaw"; -export const REPOSITORY_PROFILES: readonly RepositoryProfile[] = [ - { - targetRepo: DEFAULT_TARGET_REPO, - slug: "openclaw-openclaw", - displayName: "OpenClaw", - checkoutDir: "openclaw", - docsUrl: "https://docs.openclaw.ai", - communityUrl: "https://clawhub.ai/", - promptNote: - "Use the OpenClaw source tree, docs, changelog, and current main branch. Close proposals may use the normal OpenClaw stale/duplicate/not-in-repo/implemented-on-main policy when evidence is strong.", - applyCloseRules: { - issue: OPENCLAW_CLOSE_REASONS, - pull_request: OPENCLAW_CLOSE_REASONS.filter((reason) => reason !== "stale_insufficient_info"), - }, - }, - { - targetRepo: "openclaw/clawhub", - slug: "openclaw-clawhub", - displayName: "ClawHub", - checkoutDir: "clawhub", - communityUrl: "https://clawhub.ai/", - promptNote: - "Use the ClawHub source tree and current main branch. Review every issue and PR with the same evidence standard, but only propose auto-close for pull requests that are certainly implemented on main. Keep everything else open.", - applyCloseRules: { - issue: [], - pull_request: ["implemented_on_main"], - }, - }, - { - targetRepo: "openclaw/clawsweeper", - slug: "openclaw-clawsweeper", - displayName: "ClawSweeper", - checkoutDir: "clawsweeper", - promptNote: - "Use the ClawSweeper source tree and current main branch. Review bot automation, workflow, and documentation changes conservatively. Only propose auto-close for pull requests that are certainly implemented on main; keep issues open for maintainer triage.", - applyCloseRules: { - issue: [], - pull_request: ["implemented_on_main"], - }, - }, - { - targetRepo: "openclaw/fs-safe", - slug: "openclaw-fs-safe", - displayName: "fs-safe", - checkoutDir: "fs-safe", - promptNote: - "Use the fs-safe source tree and current main branch. Review filesystem-safety, path-handling, and package changes conservatively. Only propose auto-close for pull requests that are certainly implemented on main; keep issues open for maintainer triage.", - applyCloseRules: { - issue: [], - pull_request: ["implemented_on_main"], - }, +const CORE_OPENCLAW_PROFILE: RepositoryProfile = { + targetRepo: DEFAULT_TARGET_REPO, + slug: "openclaw-openclaw", + displayName: "OpenClaw", + checkoutDir: "openclaw", + docsUrl: "https://docs.openclaw.ai", + communityUrl: "https://clawhub.ai/", + promptNote: + "Use the OpenClaw source tree, docs, changelog, and current main branch. Close proposals may use the normal OpenClaw stale/duplicate/not-in-repo/implemented-on-main policy when evidence is strong.", + applyCloseRules: { + issue: OPENCLAW_CLOSE_REASONS, + pull_request: OPENCLAW_CLOSE_REASONS.filter((reason) => reason !== "stale_insufficient_info"), }, +}; + +const TARGET_REPOSITORY_CONFIG = readTargetRepositoryConfig(); + +export const REPOSITORY_PROFILES: RepositoryProfile[] = [ + CORE_OPENCLAW_PROFILE, + ...TARGET_REPOSITORY_CONFIG.repositories.map(configuredRepositoryProfile), ]; export function repositoryProfileFor(targetRepo: string): RepositoryProfile { @@ -91,12 +91,14 @@ export function repositoryProfileFor(targetRepo: string): RepositoryProfile { const profile = REPOSITORY_PROFILES.find( (candidate) => normalizeRepo(candidate.targetRepo) === normalized, ); - if (!profile) { - throw new Error( - `Unsupported target repo: ${targetRepo}. Known repos: ${REPOSITORY_PROFILES.map((candidate) => candidate.targetRepo).join(", ")}`, - ); - } - return profile; + if (profile) return profile; + + const fallback = fallbackRepositoryProfile(normalized); + if (fallback) return fallback; + + throw new Error( + `Unsupported target repo: ${targetRepo}. Known repos: ${REPOSITORY_PROFILES.map((candidate) => candidate.targetRepo).join(", ")}. Generic fallback: ${fallbackDescription()}`, + ); } export function repositoryProfileForSlug(slug: string): RepositoryProfile | undefined { @@ -114,3 +116,181 @@ export function isAutoCloseAllowed( ): boolean { return Boolean(profile.applyCloseRules[kind]?.includes(reason)); } + +function configuredRepositoryProfile(profile: ConfiguredRepositoryProfile): RepositoryProfile { + const targetRepo = normalizeRepo(profile.targetRepo); + const result: RepositoryProfile = { + targetRepo, + slug: slugForRepo(targetRepo), + displayName: profile.displayName, + checkoutDir: profile.checkoutDir, + promptNote: profile.promptNote, + applyCloseRules: profile.applyCloseRules, + }; + if (profile.docsUrl) result.docsUrl = profile.docsUrl; + if (profile.communityUrl) result.communityUrl = profile.communityUrl; + return result; +} + +function fallbackRepositoryProfile(normalizedTargetRepo: string): RepositoryProfile | undefined { + const fallback = TARGET_REPOSITORY_CONFIG.openclawFallback; + if (!fallback) return undefined; + + const [owner, repoName] = normalizedTargetRepo.split("/"); + if (!owner || !repoName || owner !== fallback.owner) return undefined; + if (fallback.denyRepositories.includes(normalizedTargetRepo)) return undefined; + if (!fallback.allowRepoNamePattern.test(repoName)) return undefined; + + return { + targetRepo: normalizedTargetRepo, + slug: slugForRepo(normalizedTargetRepo), + displayName: repoName, + checkoutDir: repoName, + promptNote: fallback.promptNote + .replaceAll("{target_repo}", normalizedTargetRepo) + .replaceAll("{repo_name}", repoName), + applyCloseRules: fallback.applyCloseRules, + }; +} + +function fallbackDescription(): string { + const fallback = TARGET_REPOSITORY_CONFIG.openclawFallback; + if (!fallback) return "disabled"; + const denied = + fallback.denyRepositories.length === 0 ? "" : ` except ${fallback.denyRepositories.join(", ")}`; + return `${fallback.owner}/*${denied}`; +} + +function slugForRepo(targetRepo: string): string { + return targetRepo.replace(/[^A-Za-z0-9_.-]+/g, "-"); +} + +function readTargetRepositoryConfig( + filePath = join(repoRoot(), "config", "target-repositories.json"), +): TargetRepositoryConfig { + if (!existsSync(filePath)) return { schemaVersion: 1, repositories: [] }; + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown; + return validateTargetRepositoryConfig(parsed); +} + +function validateTargetRepositoryConfig(value: unknown): TargetRepositoryConfig { + const config = record(value, "target repository config"); + const schemaVersion = numberValue(config.schema_version, "schema_version"); + if (schemaVersion !== 1) + throw new Error(`Unsupported target repository config schema: ${schemaVersion}`); + const repositories = arrayValue(config.repositories, "repositories").map((entry, index) => + validateConfiguredRepositoryProfile(entry, `repositories[${index}]`), + ); + const result: TargetRepositoryConfig = { schemaVersion: 1, repositories }; + if (config.openclaw_fallback !== undefined) { + result.openclawFallback = validateOpenClawFallbackConfig(config.openclaw_fallback); + } + return result; +} + +function validateConfiguredRepositoryProfile( + value: unknown, + label: string, +): ConfiguredRepositoryProfile { + const profile = record(value, label); + const result: ConfiguredRepositoryProfile = { + targetRepo: repoValue(profile.target_repo, `${label}.target_repo`), + displayName: stringValue(profile.display_name, `${label}.display_name`), + checkoutDir: pathSegmentValue(profile.checkout_dir, `${label}.checkout_dir`), + promptNote: stringValue(profile.prompt_note, `${label}.prompt_note`), + applyCloseRules: closeRulesValue(profile.apply_close_rules, `${label}.apply_close_rules`), + }; + if (profile.docs_url !== undefined) { + result.docsUrl = stringValue(profile.docs_url, `${label}.docs_url`); + } + if (profile.community_url !== undefined) { + result.communityUrl = stringValue(profile.community_url, `${label}.community_url`); + } + return result; +} + +function validateOpenClawFallbackConfig(value: unknown): OpenClawFallbackConfig { + const fallback = record(value, "openclaw_fallback"); + const pattern = stringValue( + fallback.allow_repo_name_pattern, + "openclaw_fallback.allow_repo_name_pattern", + ); + return { + owner: stringValue(fallback.owner, "openclaw_fallback.owner").toLowerCase(), + denyRepositories: arrayValue( + fallback.deny_repositories, + "openclaw_fallback.deny_repositories", + ).map((entry, index) => + normalizeRepo(repoValue(entry, `openclaw_fallback.deny_repositories[${index}]`)), + ), + allowRepoNamePattern: new RegExp(pattern), + promptNote: stringValue(fallback.prompt_note, "openclaw_fallback.prompt_note"), + applyCloseRules: closeRulesValue( + fallback.apply_close_rules, + "openclaw_fallback.apply_close_rules", + ), + }; +} + +function closeRulesValue( + value: unknown, + label: string, +): Partial> { + const rules = record(value, label); + const result: Partial> = {}; + for (const [kind, reasons] of Object.entries(rules)) { + if (!ITEM_KIND_SET.has(kind as RepositoryItemKind)) { + throw new Error(`${label}.${kind} has unsupported item kind`); + } + result[kind as RepositoryItemKind] = arrayValue(reasons, `${label}.${kind}`).map( + (reason, index) => closeReasonValue(reason, `${label}.${kind}[${index}]`), + ); + } + return result; +} + +function closeReasonValue(value: unknown, label: string): RepositoryCloseReason { + const reason = stringValue(value, label) as RepositoryCloseReason; + if (!CLOSE_REASON_SET.has(reason)) + throw new Error(`${label} has unsupported close reason: ${reason}`); + return reason; +} + +function repoValue(value: unknown, label: string): string { + const repo = normalizeRepo(stringValue(value, label)); + if (!/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(repo)) throw new Error(`${label} must be owner/repo`); + return repo; +} + +function pathSegmentValue(value: unknown, label: string): string { + const segment = stringValue(value, label); + if (!/^[A-Za-z0-9_.-]+$/.test(segment)) throw new Error(`${label} must be a safe path segment`); + return segment; +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "") + throw new Error(`${label} must be a string`); + return value; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number") throw new Error(`${label} must be a number`); + return value; +} + +function arrayValue(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + return value; +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function repoRoot(): string { + return dirname(dirname(fileURLToPath(import.meta.url))); +} diff --git a/test/repository-profiles.test.ts b/test/repository-profiles.test.ts index e998d4ff01..a1ccc06252 100644 --- a/test/repository-profiles.test.ts +++ b/test/repository-profiles.test.ts @@ -20,6 +20,32 @@ test("repositoryProfileFor supports fs-safe event reviews", () => { assert.deepEqual(profile.applyCloseRules.pull_request, ["implemented_on_main"]); }); +test("generic OpenClaw fallback supports conservative event-only onboarding", () => { + const profile = repositoryProfileFor("OpenClaw/example-tool"); + + assert.equal(profile.targetRepo, "openclaw/example-tool"); + assert.equal(profile.slug, "openclaw-example-tool"); + assert.equal(profile.displayName, "example-tool"); + assert.equal(profile.checkoutDir, "example-tool"); + assert.match(profile.promptNote, /generic OpenClaw onboarding profile/); + assert.deepEqual(profile.applyCloseRules.issue, []); + assert.deepEqual(profile.applyCloseRules.pull_request, ["implemented_on_main"]); +}); + +test("generic OpenClaw fallback keeps denied repositories unsupported", () => { + assert.throws( + () => repositoryProfileFor("openclaw/clawsweeper-state"), + /Unsupported target repo: openclaw\/clawsweeper-state/, + ); +}); + +test("generic fallback does not support repositories outside OpenClaw", () => { + assert.throws( + () => repositoryProfileFor("other-org/example-tool"), + /Unsupported target repo: other-org\/example-tool/, + ); +}); + test("profile lookup normalizes candidate target repos as well as input", () => { const mixedCaseProfile = { ...REPOSITORY_PROFILES[0],