feat: add generic openclaw target onboarding
Some checks failed
CI / pnpm check (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Has been cancelled
Pages / Deploy docs (push) Has been cancelled

This commit is contained in:
Peter Steinberger 2026-05-06 23:34:39 +01:00
parent fdd213207f
commit b9a420e71b
No known key found for this signature in database
7 changed files with 394 additions and 57 deletions

View File

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

View File

@ -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"]
}
}
}

View File

@ -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/<repo-slug>.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

View File

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

View File

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

View File

@ -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<Record<RepositoryItemKind, readonly RepositoryCloseReason[]>>;
}
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<Record<RepositoryItemKind, readonly RepositoryCloseReason[]>>;
}
interface OpenClawFallbackConfig {
owner: string;
denyRepositories: readonly string[];
allowRepoNamePattern: RegExp;
promptNote: string;
applyCloseRules: Partial<Record<RepositoryItemKind, readonly RepositoryCloseReason[]>>;
}
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<RepositoryCloseReason>(ALL_CLOSE_REASONS);
const ITEM_KIND_SET = new Set<RepositoryItemKind>(["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<Record<RepositoryItemKind, readonly RepositoryCloseReason[]>> {
const rules = record(value, label);
const result: Partial<Record<RepositoryItemKind, RepositoryCloseReason[]>> = {};
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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function repoRoot(): string {
return dirname(dirname(fileURLToPath(import.meta.url)));
}

View File

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