From 12eabf55719e85300d42a0b8414528b193bb715d Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Sun, 7 Jun 2026 16:44:18 -0700 Subject: [PATCH 1/2] feat: classify plugin SDK impact labels --- src/clawsweeper.ts | 668 ++++++++++++++++++++++++++++++++++++++- test/clawsweeper.test.ts | 256 +++++++++++++++ 2 files changed, 908 insertions(+), 16 deletions(-) diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index e9f65daa8e..db80a9a0f1 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -116,6 +116,13 @@ type MergeRiskLabelName = | "merge-risk: 🚨 availability" | "merge-risk: 🚨 automation" | "merge-risk: 🚨 other"; +type PluginSdkImpactLabelName = + | "plugin-sdk:private-only" + | "plugin-sdk:test-only" + | "plugin-sdk:additive-api" + | "plugin-sdk:behavior-change" + | "plugin-sdk:breaking-change" + | "plugin-sdk:architecture-change"; type MergeRiskOptionCategory = "fix_before_merge" | "accept_risk" | "pause_or_close"; type ReviewLabelName = Exclude | ImpactLabelName | MergeRiskLabelName; type ItemCategory = @@ -1314,6 +1321,45 @@ const MERGE_RISK_LABELS = [ const MERGE_RISK_LABEL_NAMES: ReadonlySet = new Set( MERGE_RISK_LABELS.map((label) => label.name), ); +const PLUGIN_SDK_IMPACT_LABELS = [ + { + name: "plugin-sdk:private-only", + color: "C5DEF5", + description: "Plugin SDK impact is limited to private or internal files.", + }, + { + name: "plugin-sdk:test-only", + color: "BFDADC", + description: "Plugin SDK impact is limited to tests or fixtures.", + }, + { + name: "plugin-sdk:additive-api", + color: "2DA44E", + description: "Plugin SDK public API adds a non-breaking surface.", + }, + { + name: "plugin-sdk:behavior-change", + color: "FBCA04", + description: "Plugin SDK behavior or contract-adjacent surface changed.", + }, + { + name: "plugin-sdk:breaking-change", + color: "D93F0B", + description: "Plugin SDK public API may break existing plugins and needs RFC.", + }, + { + name: "plugin-sdk:architecture-change", + color: "B60205", + description: "Plugin SDK architecture changed and needs RFC.", + }, +] as const satisfies readonly { + name: PluginSdkImpactLabelName; + color: string; + description: string; +}[]; +const PLUGIN_SDK_IMPACT_LABEL_NAMES: ReadonlySet = new Set( + PLUGIN_SDK_IMPACT_LABELS.map((label) => label.name), +); const ISSUE_ADVISORY_LABELS = [ { name: "issue-rating: 🦀 challenger crab", @@ -1500,6 +1546,17 @@ const IMPACT_LABEL_VALUES = new Set(IMPACT_LABELS.map((label) = const MERGE_RISK_LABEL_VALUES = new Set( MERGE_RISK_LABELS.map((label) => label.name), ); +const PLUGIN_SDK_IMPACT_LABEL_VALUES = new Set( + PLUGIN_SDK_IMPACT_LABELS.map((label) => label.name), +); +const PLUGIN_SDK_IMPACT_LABEL_SEVERITY = new Map([ + ["plugin-sdk:private-only", 1], + ["plugin-sdk:test-only", 1], + ["plugin-sdk:additive-api", 2], + ["plugin-sdk:behavior-change", 2], + ["plugin-sdk:breaking-change", 3], + ["plugin-sdk:architecture-change", 4], +]); const REVIEW_LABEL_VALUES = new Set([ "P0", "P1", @@ -8263,6 +8320,19 @@ function mergeRiskLabelsFromReport(markdown: string): MergeRiskLabelName[] { ); } +function pluginSdkImpactClassificationFromReport(markdown: string): PluginSdkImpactLabelName | "" { + const value = frontMatterValue(markdown, "plugin_sdk_impact_classification"); + if (!value || value === "none") return ""; + return pluginSdkImpactLabelFromName(value) ?? ""; +} + +function pluginSdkImpactReasonFromReport(markdown: string): string { + return ( + frontMatterValue(markdown, "plugin_sdk_impact_reason") ?? + "Current review selected this Plugin SDK impact label." + ); +} + function mergeRiskOptionsFromReport(markdown: string): MergeRiskOption[] { return frontMatterJsonArray(markdown, "merge_risk_options") .map((entry, index) => { @@ -9182,6 +9252,378 @@ export function configSurfaceChangeFromPullFilesForTest(options: { return configSurfaceChangeFromContext(options.repo ?? "openclaw/openclaw", context); } +interface PluginSdkImpactFile { + path: string; + previousPath: string; + status: string; + patch: string; + hasPatch: boolean; +} + +interface PluginSdkImpact { + classification: PluginSdkImpactLabelName | ""; + reason: string; + source: "none" | "deterministic" | "existing-label" | "truncated"; + triggeredPaths: string[]; + pathsHash: string; + truncated: boolean; +} + +function normalizePluginSdkImpactPath(value: unknown): string { + return typeof value === "string" ? value.replaceAll("\\", "/").trim() : ""; +} + +function pluginSdkImpactFile(entry: unknown): PluginSdkImpactFile { + const file = asRecord(entry); + const path = normalizePluginSdkImpactPath(file.filename); + const previousPath = normalizePluginSdkImpactPath(file.previous_filename); + const status = typeof file.status === "string" ? file.status.trim().toLowerCase() : ""; + const patch = typeof file.patch === "string" ? file.patch : ""; + return { + path, + previousPath, + status, + patch, + hasPatch: patch.trim().length > 0, + }; +} + +function pluginSdkImpactPatchTouchesPackageExport(patch: string): boolean { + return /\bplugin-sdk\b/i.test(patch); +} + +function isPluginSdkImpactScriptPath(path: string): boolean { + const name = basename(path); + return ( + path.startsWith("scripts/") && + (/\bplugin-sdk\b/i.test(name) || path.startsWith("scripts/lib/plugin-sdk-")) + ); +} + +function pluginSdkImpactCandidatePaths(file: PluginSdkImpactFile): string[] { + return [file.path, file.previousPath].filter(Boolean); +} + +function isPluginSdkImpactRepoPath(path: string, file: PluginSdkImpactFile): boolean { + if (path.startsWith("src/plugin-sdk/")) return true; + if (path.startsWith("packages/plugin-sdk/")) return true; + if (path === "docs/.generated/plugin-sdk-api-baseline.sha256") return true; + if (isPluginSdkImpactScriptPath(path)) return true; + if (path === "package.json") { + return !file.hasPatch || pluginSdkImpactPatchTouchesPackageExport(file.patch); + } + if (path === "src/plugins/types.ts" || path === "src/plugins/runtime/types.ts") { + return true; + } + if (path.startsWith("src/channels/plugins/")) return true; + return isPluginSdkPublicDependencyPath(path); +} + +function pluginSdkImpactPaths(file: PluginSdkImpactFile): string[] { + return pluginSdkImpactCandidatePaths(file).filter((path) => + isPluginSdkImpactRepoPath(path, file), + ); +} + +function isPluginSdkImpactPath(file: PluginSdkImpactFile): boolean { + return pluginSdkImpactPaths(file).length > 0; +} + +function pluginSdkImpactPatchIsUnknown(file: PluginSdkImpactFile): boolean { + return !file.hasPatch || configSurfacePatchIsTruncated(file.patch); +} + +function isPluginSdkPublicOrContractPath(path: string): boolean { + if (isTestOnlyPluginSdkImpactPath(path)) return false; + if (isPluginSdkPublicSurfaceMetadataPath(path)) return true; + if (/^src\/plugin-sdk\/[^/]+\.ts$/u.test(path)) return true; + if (path.startsWith("packages/plugin-sdk/")) return true; + if (path === "src/plugins/types.ts" || path === "src/plugins/runtime/types.ts") return true; + if (path.startsWith("src/channels/plugins/")) return true; + return isPluginSdkPublicDependencyPath(path); +} + +function pluginSdkImpactRemovesSurface(file: PluginSdkImpactFile): boolean { + const currentPathStillImpacts = file.path !== "" && isPluginSdkImpactRepoPath(file.path, file); + const previousPathImpacted = + file.previousPath !== "" && isPluginSdkImpactRepoPath(file.previousPath, file); + const currentPathStillPublic = file.path !== "" && isPluginSdkPublicOrContractPath(file.path); + const previousPathPublic = + file.previousPath !== "" && isPluginSdkPublicOrContractPath(file.previousPath); + return ( + (previousPathPublic && !currentPathStillPublic) || + (previousPathPublic && currentPathStillPublic && file.previousPath !== file.path) || + (previousPathImpacted && !currentPathStillImpacts) || + (file.status === "removed" && currentPathStillPublic) + ); +} + +function isPluginSdkPublicDependencyPath(path: string): boolean { + return ( + path.startsWith("packages/acp-core/src/") || + path.startsWith("packages/agent-core/src/") || + path.startsWith("packages/gateway-client/src/") || + path.startsWith("packages/gateway-protocol/src/") || + path.startsWith("packages/llm-core/src/") || + path.startsWith("packages/markdown-core/src/") || + path.startsWith("packages/media-core/src/") || + path.startsWith("packages/media-generation-core/src/") || + path.startsWith("packages/memory-host-sdk/src/") || + path.startsWith("packages/model-catalog-core/src/") || + path.startsWith("packages/net-policy/src/") || + path.startsWith("packages/normalization-core/src/") || + path.startsWith("packages/speech-core/") || + path.startsWith("packages/terminal-core/src/") || + path.startsWith("packages/tool-call-repair/src/") + ); +} + +function isTestOnlyPluginSdkImpactPath(path: string): boolean { + return ( + /(?:^|\/)(?:test|tests|__tests__|fixtures?)(?:\/|$)/u.test(path) || + /(?:^|\/)[^/]*[-_.]fixtures?\.[cm]?[jt]sx?$/u.test(path) || + /\.(?:test|spec|fixture|fixtures)\.[cm]?[jt]sx?$/u.test(path) || + path.startsWith("test/") + ); +} + +function isPluginSdkPublicSurfaceMetadataPath(path: string): boolean { + return ( + path === "docs/.generated/plugin-sdk-api-baseline.sha256" || + path === "scripts/lib/plugin-sdk-entrypoints.json" || + path === "scripts/lib/plugin-sdk-entries.mjs" || + path === "scripts/lib/plugin-sdk-private-local-only-subpaths.json" || + path === "src/plugin-sdk/entrypoints.ts" || + path === "package.json" || + path === "packages/plugin-sdk/package.json" + ); +} + +function pluginSdkImpactPathHash(paths: readonly string[]): string { + return createHash("sha256").update(paths.join("\n")).digest("hex"); +} + +function pluginSdkImpactLabelSeverity(label: PluginSdkImpactLabelName): number { + return PLUGIN_SDK_IMPACT_LABEL_SEVERITY.get(label) ?? 0; +} + +function pluginSdkImpactLabelFromName(label: string): PluginSdkImpactLabelName | null { + const normalized = label.trim().toLowerCase(); + return PLUGIN_SDK_IMPACT_LABEL_VALUES.has(normalized as PluginSdkImpactLabelName) + ? (normalized as PluginSdkImpactLabelName) + : null; +} + +function highestPluginSdkImpactLabel(labels: readonly string[]): PluginSdkImpactLabelName | null { + return ( + labels + .map(pluginSdkImpactLabelFromName) + .filter((label): label is PluginSdkImpactLabelName => Boolean(label)) + .sort( + (left, right) => pluginSdkImpactLabelSeverity(right) - pluginSdkImpactLabelSeverity(left), + )[0] ?? null + ); +} + +function classifyPluginSdkImpactFiles(files: readonly PluginSdkImpactFile[]): { + classification: PluginSdkImpactLabelName; + reason: string; +} { + const paths = files.flatMap(pluginSdkImpactPaths); + if (paths.every(isTestOnlyPluginSdkImpactPath)) { + return { + classification: "plugin-sdk:test-only", + reason: "All known Plugin SDK impact paths are tests or fixtures.", + }; + } + if (files.some(pluginSdkImpactRemovesSurface)) { + return { + classification: "plugin-sdk:breaking-change", + reason: "Plugin SDK public or contract-adjacent surface was removed or moved away.", + }; + } + + const metadataFiles = files.filter((file) => + pluginSdkImpactPaths(file).some(isPluginSdkPublicSurfaceMetadataPath), + ); + if (metadataFiles.some(pluginSdkImpactPatchIsUnknown)) { + return { + classification: "plugin-sdk:breaking-change", + reason: "Plugin SDK public surface metadata changed without complete patch text.", + }; + } + + const structuralMetadataFiles = metadataFiles.filter( + (file) => + !pluginSdkImpactPaths(file).includes("docs/.generated/plugin-sdk-api-baseline.sha256"), + ); + const privateOnlyMetadataFiles = structuralMetadataFiles.filter((file) => + pluginSdkImpactPaths(file).includes("scripts/lib/plugin-sdk-private-local-only-subpaths.json"), + ); + if (privateOnlyMetadataFiles.some((file) => patchHasAddedLines(file.patch))) { + return { + classification: "plugin-sdk:breaking-change", + reason: "Plugin SDK public metadata made a subpath private.", + }; + } + + const publicMetadataFiles = structuralMetadataFiles.filter( + (file) => + !pluginSdkImpactPaths(file).includes( + "scripts/lib/plugin-sdk-private-local-only-subpaths.json", + ), + ); + if (publicMetadataFiles.some((file) => patchHasRemovedLines(file.patch))) { + return { + classification: "plugin-sdk:breaking-change", + reason: "Plugin SDK public metadata removed surface lines.", + }; + } + if ( + publicMetadataFiles.some((file) => patchHasAddedLines(file.patch)) || + privateOnlyMetadataFiles.some((file) => patchHasRemovedLines(file.patch)) + ) { + return { + classification: "plugin-sdk:additive-api", + reason: "Plugin SDK public metadata added surface lines.", + }; + } + if ( + metadataFiles.some((file) => + pluginSdkImpactPaths(file).includes("docs/.generated/plugin-sdk-api-baseline.sha256"), + ) + ) { + return { + classification: "plugin-sdk:breaking-change", + reason: "Plugin SDK API baseline hash changed without additive metadata evidence.", + }; + } + if ( + paths.some( + (path) => + /^src\/plugin-sdk\/[^/]+\.ts$/u.test(path) || + path === "src/plugins/types.ts" || + path === "src/plugins/runtime/types.ts" || + path.startsWith("src/channels/plugins/") || + path.startsWith("packages/plugin-sdk/") || + isPluginSdkPublicDependencyPath(path), + ) + ) { + return { + classification: "plugin-sdk:behavior-change", + reason: "Plugin SDK public or contract-adjacent implementation changed.", + }; + } + return { + classification: "plugin-sdk:private-only", + reason: "Only private Plugin SDK support paths changed.", + }; +} + +function pluginSdkImpactFromContext( + repo: string, + context: ItemContext, + labels: readonly string[] = [], +): PluginSdkImpact { + const truncated = Boolean(context.counts?.pullFilesTruncated); + const triggeredFiles = (context.pullFiles ?? []) + .map(pluginSdkImpactFile) + .filter(isPluginSdkImpactPath); + const triggeredPaths = triggeredFiles.flatMap(pluginSdkImpactPaths).toSorted(); + const pathsHash = pluginSdkImpactPathHash(triggeredPaths); + if (!isPluginSdkImpactTargetRepo(repo)) { + return { + classification: "", + reason: "No OpenClaw Plugin SDK impact paths changed.", + source: "none", + triggeredPaths, + pathsHash, + truncated, + }; + } + const existing = highestPluginSdkImpactLabel(labels); + if (truncated) { + const truncatedClassification = "plugin-sdk:behavior-change"; + if ( + existing && + pluginSdkImpactLabelSeverity(existing) > pluginSdkImpactLabelSeverity(truncatedClassification) + ) { + return { + classification: existing, + reason: `Existing Plugin SDK impact label ${existing} is higher than truncated fallback ${truncatedClassification}.`, + source: "existing-label", + triggeredPaths, + pathsHash, + truncated, + }; + } + return { + classification: truncatedClassification, + reason: "The PR file list was truncated, so Plugin SDK impact needs maintainer review.", + source: "truncated", + triggeredPaths, + pathsHash, + truncated, + }; + } + if (triggeredPaths.length === 0) { + return { + classification: "", + reason: "No OpenClaw Plugin SDK impact paths changed.", + source: "none", + triggeredPaths, + pathsHash, + truncated, + }; + } + + const deterministic = classifyPluginSdkImpactFiles(triggeredFiles); + if ( + existing && + pluginSdkImpactLabelSeverity(existing) > + pluginSdkImpactLabelSeverity(deterministic.classification) + ) { + return { + classification: existing, + reason: `Existing Plugin SDK impact label ${existing} is higher than deterministic ${deterministic.classification}.`, + source: "existing-label", + triggeredPaths, + pathsHash, + truncated, + }; + } + return { + classification: deterministic.classification, + reason: deterministic.reason, + source: "deterministic", + triggeredPaths, + pathsHash, + truncated, + }; +} + +export function pluginSdkImpactFromPullFilesForTest(options: { + repo?: string; + labels?: readonly string[]; + pullFiles?: unknown[]; + pullFilesTruncated?: boolean; +}): PluginSdkImpact { + const counts: ItemContext["counts"] = { comments: 0, timeline: 0 }; + if (options.pullFilesTruncated !== undefined) + counts.pullFilesTruncated = options.pullFilesTruncated; + return pluginSdkImpactFromContext( + options.repo ?? "openclaw/openclaw", + { + issue: {}, + comments: [], + timeline: [], + pullFiles: options.pullFiles ?? [], + counts, + }, + options.labels ?? [], + ); +} + function isOpenClawConfigSurfacePath(path: string): boolean { return ( /^src\/config\/(?:zod-schema[^/]*|types[^/]*|schema(?:[-.][^/]*)?)\.ts$/.test(path) || @@ -9202,6 +9644,14 @@ function changedPatchLines(patch: string): string[] { .map((line) => line.slice(1).trim()); } +function patchHasRemovedLines(patch: string): boolean { + return /^-(?!-)/mu.test(patch); +} + +function patchHasAddedLines(patch: string): boolean { + return /^\+(?!\+)/mu.test(patch); +} + function configSurfaceLineNeedsUnknownMarker(line: string): boolean { const trimmed = line.trim(); return Boolean(trimmed) && !/^\/\/|^\/\*|^\*/.test(trimmed); @@ -9517,6 +9967,21 @@ function nextMergeRiskLabels( return nextLabels; } +function nextPluginSdkImpactLabels( + repo: string, + labels: readonly string[], + classification: PluginSdkImpactLabelName | "", +): string[] { + if (!isPluginSdkImpactTargetRepo(repo)) return [...labels]; + const nextLabels = labels.filter((label) => !PLUGIN_SDK_IMPACT_LABEL_NAMES.has(label)); + if (classification) nextLabels.push(classification); + return nextLabels; +} + +function isPluginSdkImpactTargetRepo(repo: string): boolean { + return normalizeRepo(repo) === "openclaw/openclaw"; +} + export function mergeRiskLabelSchemeForTest(): { name: string; color: string; @@ -9541,6 +10006,30 @@ export function mergeRiskLabelsForTest( ); } +export function pluginSdkImpactLabelSchemeForTest(): { + name: string; + color: string; + description: string; +}[] { + return PLUGIN_SDK_IMPACT_LABELS.map(({ name, color, description }) => ({ + name, + color, + description, + })); +} + +export function pluginSdkImpactLabelsForTest( + labels: readonly string[], + classification: string, + repo = "openclaw/openclaw", +): string[] { + return nextPluginSdkImpactLabels( + repo, + labels, + pluginSdkImpactLabelFromName(classification) ?? "", + ); +} + function ensurePriorityLabel(label: PriorityLabelSpec): void { try { ghWithRetry( @@ -9597,6 +10086,28 @@ function ensureMergeRiskLabel(name: MergeRiskLabelName): void { } } +function ensurePluginSdkImpactLabel(name: PluginSdkImpactLabelName): void { + const definition = PLUGIN_SDK_IMPACT_LABELS.find((label) => label.name === name); + if (!definition) return; + try { + ghWithRetry( + [ + "label", + "create", + definition.name, + "--color", + definition.color, + "--description", + definition.description, + ], + 2, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/already exists/i.test(message)) throw error; + } +} + interface IssueAdvisoryLabelState { type: string | undefined; itemCategory: string | undefined; @@ -9869,6 +10380,40 @@ function syncMergeRiskLabels(options: { return { labels: nextLabels, changed }; } +function syncPluginSdkImpactLabel(options: { + repo: string; + number: number; + labels: readonly string[]; + classification: PluginSdkImpactLabelName | ""; + dryRun: boolean; +}): { labels: string[]; changed: boolean } { + const nextLabels = nextPluginSdkImpactLabels( + options.repo, + options.labels, + options.classification, + ); + const currentLabelKeys = new Set(options.labels.map((label) => label.toLowerCase())); + const nextLabelKeys = new Set(nextLabels.map((label) => label.toLowerCase())); + const labelsToAdd = nextLabels.filter( + (label): label is PluginSdkImpactLabelName => + PLUGIN_SDK_IMPACT_LABEL_NAMES.has(label) && !currentLabelKeys.has(label.toLowerCase()), + ); + const labelsToRemove = options.labels.filter( + (label) => PLUGIN_SDK_IMPACT_LABEL_NAMES.has(label) && !nextLabelKeys.has(label.toLowerCase()), + ); + const changed = labelsToAdd.length > 0 || labelsToRemove.length > 0; + if (!changed) return { labels: nextLabels, changed }; + if (options.dryRun) return { labels: nextLabels, changed }; + for (const label of labelsToAdd) { + ensurePluginSdkImpactLabel(label); + ghWithRetry(["issue", "edit", String(options.number), "--add-label", label]); + } + for (const label of labelsToRemove) { + ghWithRetry(["issue", "edit", String(options.number), "--remove-label", label]); + } + return { labels: nextLabels, changed }; +} + function syncIssueAdvisoryLabels(options: { number: number; labels: readonly string[]; @@ -12031,6 +12576,7 @@ function isClawSweeperOwnedLabel(label: string): boolean { PRIORITY_LABEL_NAMES.has(label) || IMPACT_LABEL_NAMES.has(label) || MERGE_RISK_LABEL_NAMES.has(label) || + PLUGIN_SDK_IMPACT_LABEL_NAMES.has(label) || PR_RATING_LABEL_NAMES.has(label) || PR_STATUS_LABEL_NAMES.has(label) || label === FEATURE_SHOWCASE_LABEL || @@ -12052,6 +12598,11 @@ function desiredClawSweeperLabelsFromPublicReport( if (isPullRequest) { const realBehaviorProof = reportRealBehaviorProof(markdown); labels = nextMergeRiskLabels(labels, mergeRiskLabelsFromReport(markdown)); + labels = nextPluginSdkImpactLabels( + markdownRepository(markdown), + labels, + pluginSdkImpactClassificationFromReport(markdown), + ); labels = nextRealBehaviorProofSufficientLabels(labels, realBehaviorProof); labels = nextRealBehaviorProofMediaLabels(labels, realBehaviorProof); labels = nextPrRatingLabels(labels, reportPrRating(markdown)); @@ -12118,6 +12669,14 @@ function labelTransitionReason( ? `Current PR review merge-risk labels are ${labels.map(inlineCode).join(", ")}.` : "Current PR review selected no merge-risk labels."; } + if (PLUGIN_SDK_IMPACT_LABEL_NAMES.has(label)) { + const classification = pluginSdkImpactClassificationFromReport(markdown); + return action === "add" + ? pluginSdkImpactReasonFromReport(markdown) + : classification + ? `Current Plugin SDK impact label is ${inlineCode(classification)}.` + : "Current review selected no Plugin SDK impact label."; + } if (PR_RATING_LABEL_NAMES.has(label)) { const rating = reportPrRating(markdown); const current = ratingLabelForTier(rating.overallTier).name; @@ -12278,6 +12837,10 @@ function labelJustificationsFromPublicReport( `${TELEGRAM_VISIBLE_PROOF_LABEL_DESCRIPTION} ${sentence(telegramProof.summary)}`, ); } + const pluginSdkImpactClassification = pluginSdkImpactClassificationFromReport(markdown); + if (pluginSdkImpactClassification) { + add(pluginSdkImpactClassification, pluginSdkImpactReasonFromReport(markdown)); + } } return [...byLabel.values()]; } @@ -13374,15 +13937,52 @@ function pullHeadShaFromContext(context: ItemContext): string | null { return typeof sha === "string" && sha.trim() ? sha.trim() : null; } +function pullBaseShaFromContext(context: ItemContext): string | null { + const pull = asRecord(context.pullRequest); + const base = asRecord(pull.base); + const sha = base.sha; + return typeof sha === "string" && sha.trim() ? sha.trim() : null; +} + function pullHeadShaFromReport(markdown: string): string | null { const value = frontMatterValue(markdown, "pull_head_sha"); return value && value !== "unknown" ? value : null; } +function pullBaseShaFromReport(markdown: string): string | null { + const value = frontMatterValue(markdown, "pull_base_sha"); + return value && value !== "unknown" ? value : null; +} + function markerAttributeValue(value: string): string { return value.trim().replace(/[^\w./:@-]/g, "_") || "unknown"; } +function isFullGitSha(value: string | null): value is string { + return Boolean(value && /^[0-9a-f]{40}$/iu.test(value)); +} + +function pluginSdkImpactMarkerFromReport(markdown: string): string { + const classification = pluginSdkImpactClassificationFromReport(markdown); + if (!classification || frontMatterBoolean(markdown, "plugin_sdk_impact_paths_truncated")) { + return ""; + } + const headSha = pullHeadShaFromReport(markdown); + const baseSha = pullBaseShaFromReport(markdown); + const pathsHash = frontMatterValue(markdown, "plugin_sdk_impact_paths_hash") ?? ""; + if (!isFullGitSha(headSha) || !isFullGitSha(baseSha) || !/^[0-9a-f]{64}$/iu.test(pathsHash)) { + return ""; + } + return [ + "", + ].join(" "); +} + export function reviewAutomationMarkersFromReport(markdown: string): string { const itemKind = frontMatterValue(markdown, "type"); if (itemKind !== "pull_request") return ""; @@ -13396,13 +13996,16 @@ export function reviewAutomationMarkersFromReport(markdown: string): string { `confidence=${markerAttributeValue(confidence)}`, ].join(" "); const securityNeedsAttention = reportSecurityReview(markdown).status === "needs_attention"; + const pluginSdkImpactMarker = pluginSdkImpactMarkerFromReport(markdown); + const withPluginSdkImpactMarker = (markers: string): string => + [pluginSdkImpactMarker, markers].filter(Boolean).join("\n"); const humanReviewMarkers = (): string => { const markers = []; if (securityNeedsAttention) { markers.push(``); } markers.push(``); - return markers.join("\n"); + return withPluginSdkImpactMarker(markers.join("\n")); }; if (frontMatterValue(markdown, "review_status") === "failed") { @@ -13425,33 +14028,37 @@ export function reviewAutomationMarkersFromReport(markdown: string): string { } else { markers.push(``); } - return markers.join("\n"); + return withPluginSdkImpactMarker(markers.join("\n")); } if (hasRealBehaviorProofBlocker) { - return ``; + return withPluginSdkImpactMarker(``); } if (decision === "keep_open") { if (repairLoopPassModeFromReport(markdown)) { - return ``; + return withPluginSdkImpactMarker(``); } if (repairLoopFindingRepairAllowed(markdown)) { - return [ - ``, - ``, - ].join("\n"); + return withPluginSdkImpactMarker( + [ + ``, + ``, + ].join("\n"), + ); } if (frontMatterValue(markdown, "work_candidate") !== "queue_fix_pr") { - return ``; + return withPluginSdkImpactMarker(``); } - return [ - ``, - ``, - ].join("\n"); + return withPluginSdkImpactMarker( + [ + ``, + ``, + ].join("\n"), + ); } if (decision === "close") { - return ``; + return withPluginSdkImpactMarker(``); } - return ``; + return withPluginSdkImpactMarker(``); } function repairLoopPassModeFromReport(markdown: string): "" | "autofix" | "automerge" { @@ -14133,6 +14740,11 @@ function markdownFor(options: { const pullFiles = pullRequestFilePathsFromContext(options.context); const pullFilesTruncated = Boolean(options.context.counts?.pullFilesTruncated); const configSurfaceChange = configSurfaceChangeFromContext(options.item.repo, options.context); + const pluginSdkImpact = pluginSdkImpactFromContext( + options.item.repo, + options.context, + options.item.labels, + ); const prSurfaceFiles = prSurfaceFilesFromContext(options.context); return `--- number: ${options.item.number} @@ -14149,6 +14761,7 @@ labels: ${JSON.stringify(options.item.labels)} reviewed_at: ${new Date().toISOString()} main_sha: ${options.git.mainSha} pull_head_sha: ${pullHeadShaFromContext(options.context) ?? "unknown"} +pull_base_sha: ${pullBaseShaFromContext(options.context) ?? "unknown"} latest_release: ${options.git.latestRelease?.tagName ?? "unknown"} latest_release_sha: ${options.git.latestRelease?.sha ?? "unknown"} fixed_release: ${options.decision.fixedRelease ?? "unknown"} @@ -14204,6 +14817,12 @@ pull_files: ${jsonFrontMatterValue(pullFiles)} pull_files_truncated: ${pullFilesTruncated} config_surface_change: ${configSurfaceChange.change} config_surface_keys: ${jsonFrontMatterValue(configSurfaceChange.keys)} +plugin_sdk_impact_classification: ${pluginSdkImpact.classification || "none"} +plugin_sdk_impact_source: ${pluginSdkImpact.source} +plugin_sdk_impact_reason: ${JSON.stringify(pluginSdkImpact.reason)} +plugin_sdk_impact_paths: ${jsonFrontMatterValue(pluginSdkImpact.triggeredPaths)} +plugin_sdk_impact_paths_hash: ${pluginSdkImpact.pathsHash} +plugin_sdk_impact_paths_truncated: ${pluginSdkImpact.truncated} pr_surface_files: ${jsonFrontMatterValue(prSurfaceFiles)} pr_surface_files_truncated: ${pullFilesTruncated} item_category: ${options.decision.itemCategory} @@ -15701,6 +16320,7 @@ async function applyDecisionsCommand(args: Args): Promise { clawSweeperLabelsChanged ||= impactSyncResult.changed; markdown = replaceFrontMatterValue(markdown, "labels", JSON.stringify(item.labels)); let mergeRiskLabelsChanged = false; + let pluginSdkImpactLabelChanged = false; if (item.kind === "pull_request") { const mergeRiskSyncResult = syncMergeRiskLabels({ number, @@ -15712,8 +16332,24 @@ async function applyDecisionsCommand(args: Args): Promise { mergeRiskLabelsChanged = mergeRiskSyncResult.changed; clawSweeperLabelsChanged ||= mergeRiskSyncResult.changed; markdown = replaceFrontMatterValue(markdown, "labels", JSON.stringify(item.labels)); + const pluginSdkImpactSyncResult = syncPluginSdkImpactLabel({ + repo: markdownRepository(markdown), + number, + labels: item.labels, + classification: pluginSdkImpactClassificationFromReport(markdown), + dryRun, + }); + item.labels = pluginSdkImpactSyncResult.labels; + pluginSdkImpactLabelChanged = pluginSdkImpactSyncResult.changed; + clawSweeperLabelsChanged ||= pluginSdkImpactSyncResult.changed; + markdown = replaceFrontMatterValue(markdown, "labels", JSON.stringify(item.labels)); } - if (syncResult.changed || impactSyncResult.changed || mergeRiskLabelsChanged) { + if ( + syncResult.changed || + impactSyncResult.changed || + mergeRiskLabelsChanged || + pluginSdkImpactLabelChanged + ) { rememberSelfMutationUpdatedAt(); } } catch (error) { diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index 7005c5a76f..42951627b1 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -64,6 +64,9 @@ import { parseGhJson, parseGhJsonLines, parseDecision, + pluginSdkImpactFromPullFilesForTest, + pluginSdkImpactLabelsForTest, + pluginSdkImpactLabelSchemeForTest, mergeRiskLabelsForTest, mergeRiskLabelSchemeForTest, prRatingLabelsForTest, @@ -13046,6 +13049,58 @@ ${securitySection} assert.doesNotMatch(automergeMarkers, /clawsweeper-verdict:needs-human/); }); +test("pull request automation markers include exact-head Plugin SDK impact marker", () => { + const paths = ["src/plugin-sdk/runtime.ts", "src/plugins/types.ts"].toSorted(); + const pathHash = createHash("sha256").update(paths.join("\n")).digest("hex"); + const headSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const baseSha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const markers = reviewAutomationMarkersFromReport( + `${reportFrontMatter({ + type: "pull_request", + number: "74125", + pull_head_sha: headSha, + pull_base_sha: baseSha, + decision: "keep_open", + confidence: "high", + review_status: "complete", + work_candidate: "none", + plugin_sdk_impact_classification: "plugin-sdk:behavior-change", + plugin_sdk_impact_paths: JSON.stringify(paths), + plugin_sdk_impact_paths_hash: pathHash, + plugin_sdk_impact_paths_truncated: "false", + })}`, + ); + + assert.match( + markers, + new RegExp( + `clawsweeper-plugin-sdk-impact sha=${headSha} base=${baseSha} paths=${pathHash} classification=plugin-sdk:behavior-change`, + ), + ); + assert.match(markers, /clawsweeper-verdict:needs-human/); +}); + +test("pull request automation markers suppress Plugin SDK impact marker when file list is truncated", () => { + const markers = reviewAutomationMarkersFromReport( + `${reportFrontMatter({ + type: "pull_request", + number: "74126", + pull_head_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + pull_base_sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + decision: "keep_open", + confidence: "high", + review_status: "complete", + work_candidate: "none", + plugin_sdk_impact_classification: "plugin-sdk:behavior-change", + plugin_sdk_impact_paths_hash: "c".repeat(64), + plugin_sdk_impact_paths_truncated: "true", + })}`, + ); + + assert.doesNotMatch(markers, /clawsweeper-plugin-sdk-impact/); + assert.match(markers, /clawsweeper-verdict:needs-human/); +}); + test("pull request keep-open review comments suppress duplicate remaining risk text", () => { const duplicateRisk = "Run the automerge smoke after the repair lane is green."; const comment = renderReviewCommentFromReport( @@ -16092,6 +16147,207 @@ test("ClawSweeper impact labels remove stale owned labels and preserve unrelated assert.deepEqual(impactLabelsForTest(["bug", "impact:auth-provider"], []), ["bug"]); }); +test("ClawSweeper Plugin SDK impact labels expose the CI classification taxonomy", () => { + const labels = pluginSdkImpactLabelSchemeForTest(); + assert.deepEqual( + labels.map((label) => label.name), + [ + "plugin-sdk:private-only", + "plugin-sdk:test-only", + "plugin-sdk:additive-api", + "plugin-sdk:behavior-change", + "plugin-sdk:breaking-change", + "plugin-sdk:architecture-change", + ], + ); + for (const label of labels) { + assert.ok( + label.description.length <= 100, + `${label.name} description is ${label.description.length} characters`, + ); + } +}); + +test("ClawSweeper Plugin SDK impact labels remove stale owned labels and preserve unrelated labels", () => { + assert.deepEqual( + pluginSdkImpactLabelsForTest( + ["plugin-sdk:private-only", "plugin-sdk:behavior-change", "bug"], + "plugin-sdk:breaking-change", + ), + ["bug", "plugin-sdk:breaking-change"], + ); + assert.deepEqual(pluginSdkImpactLabelsForTest(["bug", "plugin-sdk:test-only"], ""), ["bug"]); + assert.deepEqual( + pluginSdkImpactLabelsForTest( + ["bug", "plugin-sdk:architecture-change"], + "", + "openclaw/clawsweeper", + ), + ["bug", "plugin-sdk:architecture-change"], + ); +}); + +test("ClawSweeper classifies Plugin SDK impact from PR files", () => { + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "scripts/lib/plugin-sdk-entrypoints.json", + patch: '@@\n+ "new-runtime",', + }, + ], + }).classification, + "plugin-sdk:additive-api", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "scripts/lib/plugin-sdk-entrypoints.json", + patch: '@@\n- "old-runtime",', + }, + ], + }).classification, + "plugin-sdk:breaking-change", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "scripts/lib/plugin-sdk-entrypoints.json", + patch: "", + }, + ], + }).classification, + "plugin-sdk:breaking-change", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "scripts/lib/plugin-sdk-entrypoints.json", + patch: '@@\n+ "new-runtime",\n\n[truncated 120 chars]', + }, + ], + }).classification, + "plugin-sdk:breaking-change", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [{ filename: "src/plugin-sdk/runtime.ts", patch: "@@\n+export const value = 1;" }], + }).classification, + "plugin-sdk:behavior-change", + ); + for (const filename of [ + "packages/acp-core/src/runtime/types.ts", + "packages/gateway-client/src/readiness.ts", + "packages/media-core/src/mime.ts", + "packages/media-generation-core/src/model-ref.ts", + "packages/net-policy/src/ip.ts", + ]) { + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [{ filename, patch: "@@\n+export const value = 1;" }], + }).classification, + "plugin-sdk:behavior-change", + filename, + ); + } + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "src/internal/runtime.ts", + previous_filename: "src/plugin-sdk/runtime.ts", + status: "renamed", + patch: "", + }, + ], + }).classification, + "plugin-sdk:breaking-change", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "src/plugin-sdk/internal/runtime.ts", + previous_filename: "src/plugin-sdk/runtime.ts", + status: "renamed", + patch: "", + }, + ], + }).classification, + "plugin-sdk:breaking-change", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "src/plugin-sdk/runtime-v2.ts", + previous_filename: "src/plugin-sdk/runtime.ts", + status: "renamed", + patch: "", + }, + ], + }).classification, + "plugin-sdk:breaking-change", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "src/plugin-sdk/internal/helper.ts", + status: "removed", + patch: "", + }, + ], + }).classification, + "plugin-sdk:private-only", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { filename: "src/plugin-sdk/runtime.test.ts", patch: "@@\n+test('x', () => {})" }, + ], + }).classification, + "plugin-sdk:test-only", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [ + { + filename: "packages/net-policy/src/ip-test-fixtures.ts", + patch: "@@\n+export const ip = '127.0.0.1';", + }, + ], + }).classification, + "plugin-sdk:test-only", + ); + assert.equal( + pluginSdkImpactFromPullFilesForTest({ + pullFiles: [{ filename: "docs/readme.md", patch: "@@\n+Docs" }], + pullFilesTruncated: true, + }).classification, + "plugin-sdk:behavior-change", + ); + const truncatedWithExisting = pluginSdkImpactFromPullFilesForTest({ + labels: ["plugin-sdk:architecture-change"], + pullFiles: [{ filename: "docs/readme.md", patch: "@@\n+Docs" }], + pullFilesTruncated: true, + }); + assert.equal(truncatedWithExisting.classification, "plugin-sdk:architecture-change"); + assert.equal(truncatedWithExisting.source, "existing-label"); +}); + +test("ClawSweeper Plugin SDK classification preserves a higher existing label", () => { + const impact = pluginSdkImpactFromPullFilesForTest({ + labels: ["plugin-sdk:architecture-change"], + pullFiles: [{ filename: "src/plugin-sdk/runtime.ts", patch: "@@\n+export const value = 1;" }], + }); + assert.equal(impact.classification, "plugin-sdk:architecture-change"); + assert.equal(impact.source, "existing-label"); +}); + test("ClawSweeper impact labels do not alter PR review finding priorities", () => { const decision = parseDecision( closeDecision({ From 8b117f854c8ce7ac227d3806d6c470ad5a0d2f9d Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Sun, 7 Jun 2026 19:38:16 -0700 Subject: [PATCH 2/2] feat: publish plugin SDK impact checks --- .github/workflows/sweep.yml | 6 + src/clawsweeper.ts | 459 +++++++++++++++++++++++++++++++++++- test/clawsweeper.test.ts | 194 +++++++++++++++ 3 files changed, 655 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index 349bb23280..da3449ecfd 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -237,7 +237,9 @@ jobs: owner: ${{ steps.target.outputs.target_repo_owner }} repositories: ${{ steps.target.outputs.target_repo_name }} permission-contents: write + permission-checks: write permission-issues: write + permission-members: read permission-pull-requests: write - name: React to target item review start @@ -1194,7 +1196,9 @@ jobs: owner: ${{ needs.plan.outputs.target_repo_owner }} repositories: ${{ needs.plan.outputs.target_repo_name }} permission-contents: write + permission-checks: write permission-issues: write + permission-members: read permission-pull-requests: write - name: Create state token @@ -1876,7 +1880,9 @@ jobs: owner: ${{ steps.target.outputs.target_repo_owner }} repositories: ${{ steps.target.outputs.target_repo_name }} permission-contents: write + permission-checks: write permission-issues: write + permission-members: read permission-pull-requests: write - name: Create state token diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts index db80a9a0f1..f0e2d2f896 100644 --- a/src/clawsweeper.ts +++ b/src/clawsweeper.ts @@ -123,6 +123,7 @@ type PluginSdkImpactLabelName = | "plugin-sdk:behavior-change" | "plugin-sdk:breaking-change" | "plugin-sdk:architecture-change"; +type PluginSdkImpactCheckConclusion = "success" | "failure"; type MergeRiskOptionCategory = "fix_before_merge" | "accept_risk" | "pause_or_close"; type ReviewLabelName = Exclude | ImpactLabelName | MergeRiskLabelName; type ItemCategory = @@ -219,6 +220,7 @@ type ActionTaken = | "retry_pr_close_coverage_proof" | "skipped_invalid_decision" | "skipped_missing_record" + | "plugin_sdk_impact_check_synced" | "skipped_runtime_budget"; const MAINTAINER_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); @@ -1360,6 +1362,19 @@ const PLUGIN_SDK_IMPACT_LABELS = [ const PLUGIN_SDK_IMPACT_LABEL_NAMES: ReadonlySet = new Set( PLUGIN_SDK_IMPACT_LABELS.map((label) => label.name), ); +const PLUGIN_SDK_IMPACT_CHECK_NAME = "Plugin SDK impact gate"; +const PLUGIN_SDK_IMPACT_MAINTAINER_APPROVAL_LABELS: ReadonlySet = new Set( + [ + "plugin-sdk:additive-api", + "plugin-sdk:behavior-change", + "plugin-sdk:breaking-change", + "plugin-sdk:architecture-change", + ], +); +const PLUGIN_SDK_IMPACT_RFC_LABELS: ReadonlySet = new Set([ + "plugin-sdk:breaking-change", + "plugin-sdk:architecture-change", +]); const ISSUE_ADVISORY_LABELS = [ { name: "issue-rating: 🦀 challenger crab", @@ -13962,6 +13977,410 @@ function isFullGitSha(value: string | null): value is string { return Boolean(value && /^[0-9a-f]{40}$/iu.test(value)); } +interface GitHubPullRequestReview { + state?: string; + commit_id?: string | null; + submitted_at?: string | null; + user?: GitHubUser | null; +} + +interface GitHubTeamMembership { + state?: string; +} + +interface GitHubPullRequestDetails { + body?: string | null; + html_url?: string; +} + +interface GitHubCheckRun { + id?: number; +} + +interface PluginSdkImpactRequirementStatus { + maintainerApprovalRequired: boolean; + maintainerApprovalSatisfied: boolean; + maintainerApprovers: string[]; + rfcRequired: boolean; + rfcSatisfied: boolean; + mergedRfcPulls: number[]; +} + +interface PluginSdkImpactCheckState { + repo: string; + number: number; + headSha: string; + classification: PluginSdkImpactLabelName; + reason: string; + source: string; + triggeredPaths: string[]; + pathsTruncated: boolean; + detailsUrl: string; + requirements: PluginSdkImpactRequirementStatus; +} + +interface PluginSdkImpactCheckPayload { + name: string; + head_sha: string; + status: "completed"; + conclusion: PluginSdkImpactCheckConclusion; + completed_at?: string; + details_url: string; + output: { + title: string; + summary: string; + text: string; + }; +} + +function pluginSdkImpactRequiresMaintainerApproval( + classification: PluginSdkImpactLabelName, +): boolean { + return PLUGIN_SDK_IMPACT_MAINTAINER_APPROVAL_LABELS.has(classification); +} + +function pluginSdkImpactRequiresRfc(classification: PluginSdkImpactLabelName): boolean { + return PLUGIN_SDK_IMPACT_RFC_LABELS.has(classification); +} + +function pluginSdkImpactRequirementStatus( + classification: PluginSdkImpactLabelName, + options: { + maintainerApprovers?: readonly string[]; + mergedRfcPulls?: readonly number[]; + } = {}, +): PluginSdkImpactRequirementStatus { + const maintainerApprovers = [...(options.maintainerApprovers ?? [])].toSorted(); + const mergedRfcPulls = [...(options.mergedRfcPulls ?? [])].toSorted( + (left, right) => left - right, + ); + const maintainerApprovalRequired = pluginSdkImpactRequiresMaintainerApproval(classification); + const rfcRequired = pluginSdkImpactRequiresRfc(classification); + return { + maintainerApprovalRequired, + maintainerApprovalSatisfied: !maintainerApprovalRequired || maintainerApprovers.length > 0, + maintainerApprovers, + rfcRequired, + rfcSatisfied: !rfcRequired || mergedRfcPulls.length > 0, + mergedRfcPulls, + }; +} + +function pluginSdkImpactCheckConclusion( + requirements: PluginSdkImpactRequirementStatus, +): PluginSdkImpactCheckConclusion { + return requirements.maintainerApprovalSatisfied && requirements.rfcSatisfied + ? "success" + : "failure"; +} + +function pluginSdkImpactCheckText(state: PluginSdkImpactCheckState): string { + const requirements = state.requirements; + const missing: string[] = []; + if (!requirements.maintainerApprovalSatisfied) { + missing.push("current-head approval from an active openclaw/maintainer member"); + } + if (!requirements.rfcSatisfied) { + missing.push("a merged openclaw/rfcs PR linked from the PR body"); + } + const displayedPaths = state.triggeredPaths.slice(0, 25); + const extraPathCount = Math.max(0, state.triggeredPaths.length - displayedPaths.length); + const pathLines = displayedPaths.length + ? displayedPaths.map((path) => `- ${path}`) + : ["- No triggered paths were recorded in the report metadata."]; + if (extraPathCount > 0) { + pathLines.push(`- ... ${extraPathCount} more`); + } + + const maintainerLine = requirements.maintainerApprovalRequired + ? requirements.maintainerApprovalSatisfied + ? `Maintainer approval: satisfied by ${requirements.maintainerApprovers + .map((login) => `@${login}`) + .join(", ")}.` + : "Maintainer approval: required and missing." + : "Maintainer approval: not required for this classification."; + const rfcLine = requirements.rfcRequired + ? requirements.rfcSatisfied + ? `RFC: satisfied by openclaw/rfcs#${requirements.mergedRfcPulls.join(", #")}.` + : "RFC: required and missing." + : "RFC: not required for this classification."; + const statusLine = missing.length + ? `Status: blocked on ${missing.join(" and ")}.` + : "Status: passed."; + + return [ + `Plugin SDK impact label: ${state.classification}`, + `Classification source: ${state.source || "unknown"}`, + `Reason: ${state.reason}`, + `File list truncated: ${state.pathsTruncated ? "yes" : "no"}`, + "", + "Triggered files:", + ...pathLines, + "", + statusLine, + maintainerLine, + rfcLine, + "", + "How to clear this gate:", + "- Keep exactly one plugin-sdk:* label on the PR. ClawSweeper normally manages this label.", + "- For plugin-sdk:additive-api or plugin-sdk:behavior-change, get an approval on the current head SHA from an active openclaw/maintainer member.", + "- For plugin-sdk:breaking-change or plugin-sdk:architecture-change, link a merged openclaw/rfcs PR in the PR body.", + "- If the label is wrong, a maintainer should update the plugin-sdk:* label or rerun ClawSweeper so it republishes this check for the current head.", + ].join("\n"); +} + +function buildPluginSdkImpactCheckPayload( + state: PluginSdkImpactCheckState, +): PluginSdkImpactCheckPayload { + const conclusion = pluginSdkImpactCheckConclusion(state.requirements); + const title = + conclusion === "success" ? "Plugin SDK impact gate passed" : "Plugin SDK impact gate blocked"; + const summary = + conclusion === "success" + ? `${state.classification} is classified and all required review/RFC gates are satisfied.` + : `${state.classification} is classified but required maintainer review or RFC evidence is missing.`; + return { + name: PLUGIN_SDK_IMPACT_CHECK_NAME, + head_sha: state.headSha, + status: "completed", + conclusion, + details_url: state.detailsUrl, + output: { + title, + summary, + text: truncateText(pluginSdkImpactCheckText(state), 60_000), + }, + }; +} + +export function pluginSdkImpactCheckPayloadForTest(options: { + repo?: string; + number?: number; + headSha?: string; + classification: PluginSdkImpactLabelName; + reason?: string; + source?: string; + triggeredPaths?: readonly string[]; + pathsTruncated?: boolean; + maintainerApprovers?: readonly string[]; + mergedRfcPulls?: readonly number[]; +}): PluginSdkImpactCheckPayload { + const classification = options.classification; + const requirementOptions: { + maintainerApprovers?: readonly string[]; + mergedRfcPulls?: readonly number[]; + } = {}; + if (options.maintainerApprovers) + requirementOptions.maintainerApprovers = options.maintainerApprovers; + if (options.mergedRfcPulls) requirementOptions.mergedRfcPulls = options.mergedRfcPulls; + return buildPluginSdkImpactCheckPayload({ + repo: options.repo ?? "openclaw/openclaw", + number: options.number ?? 1, + headSha: options.headSha ?? "a".repeat(40), + classification, + reason: options.reason ?? "Test classification reason.", + source: options.source ?? "deterministic", + triggeredPaths: [...(options.triggeredPaths ?? [])], + pathsTruncated: options.pathsTruncated ?? false, + detailsUrl: `https://github.com/${options.repo ?? "openclaw/openclaw"}/pull/${ + options.number ?? 1 + }`, + requirements: pluginSdkImpactRequirementStatus(classification, requirementOptions), + }); +} + +function pluginSdkImpactCheckPayloadHash(payload: PluginSdkImpactCheckPayload): string { + const { completed_at: _completedAt, ...stablePayload } = payload; + return sha256(stableJson(stablePayload)); +} + +function pluginSdkImpactCheckRunsForCommit(repo: string, sha: string): GitHubCheckRun[] { + const encodedName = encodeURIComponent(PLUGIN_SDK_IMPACT_CHECK_NAME); + try { + const runs = ghJson([ + "api", + `repos/${repo}/commits/${sha}/check-runs?check_name=${encodedName}`, + "--jq", + ".check_runs", + ]); + return Array.isArray(runs) ? (runs as GitHubCheckRun[]) : []; + } catch { + return []; + } +} + +function activeOpenClawMaintainer(login: string): boolean { + const membership = ghJson([ + "api", + `orgs/openclaw/teams/maintainer/memberships/${login}`, + ]); + return membership.state === "active"; +} + +function pluginSdkImpactMaintainerApprovers( + repo: string, + number: number, + headSha: string, +): string[] { + const reviews = ghJson([ + "api", + `repos/${repo}/pulls/${number}/reviews?per_page=100`, + ]); + const latestByLogin = new Map(); + for (const review of reviews) { + const login = review.user?.login; + const state = review.state?.toUpperCase(); + if (!login || !state || !["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(state)) { + continue; + } + latestByLogin.set(login, review); + } + const approvers: string[] = []; + for (const [login, review] of latestByLogin) { + if (review.state?.toUpperCase() !== "APPROVED") continue; + if (review.commit_id !== headSha) continue; + if (activeOpenClawMaintainer(login)) approvers.push(login); + } + return approvers.toSorted(); +} + +function openClawRfcPullNumbers(text: string): number[] { + const numbers = new Set(); + const patterns = [ + /https:\/\/github\.com\/openclaw\/rfcs\/pull\/(\d+)/giu, + /\bopenclaw\/rfcs#(\d+)\b/giu, + ]; + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const number = Number(match[1]); + if (Number.isInteger(number) && number > 0) numbers.add(number); + } + } + return [...numbers].toSorted((left, right) => left - right); +} + +function mergedOpenClawRfcPulls(text: string): number[] { + return openClawRfcPullNumbers(text).filter((number) => { + const pull = ghJson<{ merged_at?: string | null }>([ + "api", + `repos/openclaw/rfcs/pulls/${number}`, + ]); + return Boolean(pull.merged_at); + }); +} + +function pullRequestBody(repo: string, number: number): string { + const pull = ghJson(["api", `repos/${repo}/pulls/${number}`]); + return pull.body ?? ""; +} + +function pluginSdkImpactCheckStateFromReport( + markdown: string, + number: number, +): PluginSdkImpactCheckState | null { + const classification = pluginSdkImpactClassificationFromReport(markdown); + if (!classification) return null; + const headSha = pullHeadShaFromReport(markdown); + if (!isFullGitSha(headSha)) return null; + const repo = markdownRepository(markdown); + const maintainerApprovers = pluginSdkImpactRequiresMaintainerApproval(classification) + ? pluginSdkImpactMaintainerApprovers(repo, number, headSha) + : []; + const prBody = pluginSdkImpactRequiresRfc(classification) ? pullRequestBody(repo, number) : ""; + const mergedRfcPulls = prBody ? mergedOpenClawRfcPulls(prBody) : []; + return { + repo, + number, + headSha, + classification, + reason: pluginSdkImpactReasonFromReport(markdown), + source: frontMatterValue(markdown, "plugin_sdk_impact_source") ?? "unknown", + triggeredPaths: frontMatterStringArray(markdown, "plugin_sdk_impact_paths"), + pathsTruncated: frontMatterBoolean(markdown, "plugin_sdk_impact_paths_truncated"), + detailsUrl: + frontMatterValue(markdown, "review_comment_url") ?? + frontMatterValue(markdown, "url") ?? + `https://github.com/${repo}/pull/${number}`, + requirements: pluginSdkImpactRequirementStatus(classification, { + maintainerApprovers, + mergedRfcPulls, + }), + }; +} + +function syncPluginSdkImpactCheckRun(options: { + markdown: string; + number: number; + dryRun: boolean; +}): { markdown: string; changed: boolean; reason: string } { + const state = pluginSdkImpactCheckStateFromReport(options.markdown, options.number); + if (!state) { + return { markdown: options.markdown, changed: false, reason: "no Plugin SDK impact check" }; + } + const payload = buildPluginSdkImpactCheckPayload(state); + const desiredHash = pluginSdkImpactCheckPayloadHash(payload); + const existingHash = frontMatterValue(options.markdown, "plugin_sdk_impact_check_sha256"); + const existing = pluginSdkImpactCheckRunsForCommit(state.repo, state.headSha).find((run) => + Boolean(run.id), + ); + const changed = !existing?.id || existingHash !== desiredHash; + if (!changed) { + return { + markdown: options.markdown, + changed: false, + reason: "Plugin SDK impact check already current", + }; + } + if (!options.dryRun) { + const payloadWithTimestamp = { ...payload, completed_at: new Date().toISOString() }; + const payloadDir = join(ROOT, ".artifacts"); + ensureDir(payloadDir); + const payloadPath = join( + payloadDir, + `plugin-sdk-impact-check-${options.number}-${state.headSha}.json`, + ); + writeFileSync(payloadPath, JSON.stringify(payloadWithTimestamp, null, 2), "utf8"); + if (existing?.id) { + ghWithRetry([ + "api", + `repos/${state.repo}/check-runs/${existing.id}`, + "--method", + "PATCH", + "--input", + payloadPath, + ]); + } else { + ghWithRetry([ + "api", + `repos/${state.repo}/check-runs`, + "--method", + "POST", + "--input", + payloadPath, + ]); + } + } + let markdown = replaceFrontMatterValue( + options.markdown, + "plugin_sdk_impact_check_sha256", + desiredHash, + ); + if (!options.dryRun) { + markdown = replaceFrontMatterValue( + markdown, + "plugin_sdk_impact_check_synced_at", + new Date().toISOString(), + ); + } + const conclusion = pluginSdkImpactCheckConclusion(state.requirements); + const action = options.dryRun ? "would sync" : "synced"; + return { + markdown, + changed: true, + reason: `${action} Plugin SDK impact check (${conclusion})`, + }; +} + function pluginSdkImpactMarkerFromReport(markdown: string): string { const classification = pluginSdkImpactClassificationFromReport(markdown); if (!classification || frontMatterBoolean(markdown, "plugin_sdk_impact_paths_truncated")) { @@ -16299,6 +16718,8 @@ async function applyDecisionsCommand(args: Args): Promise { } const isCurrentCompleteReport = frontMatterValue(markdown, "review_status") === "complete" && unchangedSinceReview; + let pluginSdkImpactCheckChanged = false; + let pluginSdkImpactCheckReason = ""; if (state === "open" && isCurrentCompleteReport) { try { const syncResult = syncPriorityLabel({ @@ -16343,6 +16764,14 @@ async function applyDecisionsCommand(args: Args): Promise { pluginSdkImpactLabelChanged = pluginSdkImpactSyncResult.changed; clawSweeperLabelsChanged ||= pluginSdkImpactSyncResult.changed; markdown = replaceFrontMatterValue(markdown, "labels", JSON.stringify(item.labels)); + const pluginSdkImpactCheckSyncResult = syncPluginSdkImpactCheckRun({ + markdown, + number, + dryRun, + }); + markdown = pluginSdkImpactCheckSyncResult.markdown; + pluginSdkImpactCheckChanged = pluginSdkImpactCheckSyncResult.changed; + pluginSdkImpactCheckReason = pluginSdkImpactCheckSyncResult.reason; } if ( syncResult.changed || @@ -16352,6 +16781,13 @@ async function applyDecisionsCommand(args: Args): Promise { ) { rememberSelfMutationUpdatedAt(); } + if (pluginSdkImpactCheckChanged && !dryRun) { + markdown = replaceFrontMatterValue( + markdown, + "apply_checked_at", + new Date().toISOString(), + ); + } } catch (error) { if (!isGitHubRequiresAuthenticationError(error)) throw error; if (markLabelSyncAuthSkipped("ClawSweeper")) break; @@ -16524,6 +16960,18 @@ async function applyDecisionsCommand(args: Args): Promise { const labelSyncProgressMessage = issueAdvisoryLabelsChanged ? `synced advisory issue labels #${number}` : `synced ClawSweeper labels #${number}`; + const metadataSyncReasons = [ + clawSweeperLabelsChanged ? labelSyncReason : "", + pluginSdkImpactCheckChanged ? pluginSdkImpactCheckReason : "", + ].filter(Boolean); + const metadataSyncAction: ActionTaken = + pluginSdkImpactCheckChanged && !clawSweeperLabelsChanged + ? "plugin_sdk_impact_check_synced" + : "kept_open"; + const metadataSyncProgressMessage = + pluginSdkImpactCheckChanged && !clawSweeperLabelsChanged + ? `synced Plugin SDK impact check #${number}` + : labelSyncProgressMessage; if (needsReviewCommentSync) { const lockedReason = needsReviewCommentBodySync ? lockedConversationApplyReason(item) : null; if (lockedReason) { @@ -16563,6 +17011,9 @@ async function applyDecisionsCommand(args: Args): Promise { } else if (needsReviewCommentSync) { syncReasons.push("recorded existing durable comment metadata"); } + if (pluginSdkImpactCheckChanged) { + syncReasons.push(pluginSdkImpactCheckReason); + } if (needsReviewCommentSync) { markdown = updateReviewCommentMetadata(markdown, syncedComment, markedReviewComment); } @@ -16593,18 +17044,18 @@ async function applyDecisionsCommand(args: Args): Promise { continue; } if ( - clawSweeperLabelsChanged && + (clawSweeperLabelsChanged || pluginSdkImpactCheckChanged) && !needsReviewCommentSync && (!isCloseProposal || syncCommentsOnly) ) { if (!dryRun) writeFileSync(path, markdown, "utf8"); results.push({ number, - action: "kept_open", - reason: labelSyncReason, + action: metadataSyncAction, + reason: metadataSyncReasons.join("; "), }); processedCount += 1; - maybeLogProgress(labelSyncProgressMessage); + maybeLogProgress(metadataSyncProgressMessage); if (processedCount >= processedLimit) break; } if (syncCommentsOnly) continue; diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts index 42951627b1..43a03a180c 100644 --- a/test/clawsweeper.test.ts +++ b/test/clawsweeper.test.ts @@ -64,6 +64,7 @@ import { parseGhJson, parseGhJsonLines, parseDecision, + pluginSdkImpactCheckPayloadForTest, pluginSdkImpactFromPullFilesForTest, pluginSdkImpactLabelsForTest, pluginSdkImpactLabelSchemeForTest, @@ -6003,6 +6004,171 @@ if (args[0] === "api" && /\\/issues\\/74478$/.test(path)) { } }); +test("apply-decisions publishes Plugin SDK impact check runs with maintainer guidance", () => { + const root = mkdtempSync(tmpPrefix); + try { + const itemsDir = join(root, "items"); + const closedDir = join(root, "closed"); + const plansDir = join(root, "plans"); + const reportPath = join(root, "apply-report.json"); + const logPath = join(root, "gh.log"); + const itemPath = join(itemsDir, "74480.md"); + const headSha = "0123456789abcdef0123456789abcdef01234567"; + mkdirSync(itemsDir, { recursive: true }); + mkdirSync(plansDir, { recursive: true }); + writeFileSync( + itemPath, + `${reportFrontMatter({ + repository: "openclaw/openclaw", + type: "pull_request", + number: "74480", + title: "Update plugin SDK behavior", + url: "https://github.com/openclaw/openclaw/pull/74480", + decision: "keep_open", + close_reason: "none", + confidence: "high", + action_taken: "kept_open", + review_status: "complete", + local_checkout_access: "verified", + author: "contributor", + author_association: "CONTRIBUTOR", + labels: JSON.stringify(["plugin-sdk:behavior-change"]), + item_category: "feature", + item_snapshot_hash: "snapshot-a", + item_updated_at: "2026-05-19T20:00:00Z", + pull_head_sha: headSha, + plugin_sdk_impact_classification: "plugin-sdk:behavior-change", + plugin_sdk_impact_source: "deterministic", + plugin_sdk_impact_reason: JSON.stringify( + "Plugin SDK public or contract-adjacent implementation changed.", + ), + plugin_sdk_impact_paths: JSON.stringify(["src/plugin-sdk/runtime.ts"]), + plugin_sdk_impact_paths_truncated: "false", + })} + +## Summary + +This PR has complete review metadata and needs a Plugin SDK impact check. + +${realBehaviorProofReportSection({ status: "not_applicable", evidenceKind: "not_applicable" })} + +${prRatingReportSection({ overallTier: "A" })} + +## Review Findings + +Overall correctness: patch is correct + +Overall confidence: 0.9 + +Full review comments: + +- none +`, + "utf8", + ); + + const ghMock = ` +const { appendFileSync, readFileSync } = require("fs"); +const logPath = ${JSON.stringify(logPath)}; +const headSha = ${JSON.stringify(headSha)}; +const rawArgs = process.argv.slice(2); +const args = rawArgs[0] === "--repo" ? rawArgs.slice(2) : rawArgs; +appendFileSync(logPath, JSON.stringify(args) + "\\n"); +const path = args[1] || ""; +if (args[0] === "api" && /\\/issues\\/74480$/.test(path)) { + console.log(JSON.stringify({ + number: 74480, + title: "Update plugin SDK behavior", + html_url: "https://github.com/openclaw/openclaw/pull/74480", + created_at: "2026-05-19T19:00:00Z", + updated_at: "2026-05-19T20:00:00Z", + closed_at: null, + state: "open", + locked: false, + active_lock_reason: null, + author_association: "CONTRIBUTOR", + user: { login: "contributor" }, + labels: ["plugin-sdk:behavior-change"], + pull_request: {} + })); +} else if (args[0] === "api" && args[1] === "-i" && /\\/issues\\/74480\\/timeline(?:\\?|$)/.test(args[2] || "")) { + console.log("HTTP/2 200\\n\\n[]"); +} else if (args[0] === "api" && /\\/issues\\/74480\\/timeline(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[]])); +} else if (args[0] === "api" && /\\/pulls\\/74480\\/reviews(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([{ state: "APPROVED", commit_id: headSha, user: { login: "maintainer" } }])); +} else if (args[0] === "api" && /orgs\\/openclaw\\/teams\\/maintainer\\/memberships\\/maintainer$/.test(path)) { + console.log(JSON.stringify({ state: "active" })); +} else if (args[0] === "api" && /\\/commits\\/${headSha}\\/check-runs/.test(path)) { + console.log(JSON.stringify([])); +} else if (args[0] === "api" && /\\/check-runs$/.test(path) && args.includes("POST")) { + const input = args[args.indexOf("--input") + 1]; + appendFileSync(logPath, JSON.stringify(["check-run-payload", JSON.parse(readFileSync(input, "utf8"))]) + "\\n"); + console.log(JSON.stringify({ id: 1234 })); +} else if (args[0] === "api" && /\\/pulls\\/74480$/.test(path)) { + console.log(JSON.stringify({ + number: 74480, + html_url: "https://github.com/openclaw/openclaw/pull/74480", + state: "open", + changed_files: 1, + commits: 1, + review_comments: 0, + body: "", + head: { sha: headSha, ref: "branch", repo: { full_name: "fork/openclaw" } }, + base: { sha: "base-sha", ref: "main", repo: { full_name: "openclaw/openclaw" } }, + user: { login: "contributor" } + })); +} else if (args[0] === "api" && /\\/pulls\\/74480\\/(files|commits|comments)(?:\\?|$)/.test(path)) { + console.log(JSON.stringify([[]])); +} else if (args[0] === "api" && /\\/issues\\/74480\\/comments(?:\\?|$)/.test(path)) { + if (args.includes("--method") && args.includes("POST")) { + console.log(JSON.stringify({ + id: 987480, + html_url: "https://github.com/openclaw/openclaw/pull/74480#issuecomment-987480" + })); + } else { + console.log(JSON.stringify([[]])); + } +} else if (args[0] === "label" && args[1] === "create") { + console.log(JSON.stringify({ name: args[2] })); +} else if (args[0] === "issue" && args[1] === "edit") { + console.log(""); +} else { + console.error("unexpected gh args", JSON.stringify(args)); + process.exit(1); +} +`; + withMockGh(root, ghMock, () => { + runApplyDecisionsForTest({ + targetRepo: "openclaw/openclaw", + itemsDir, + closedDir, + plansDir, + reportPath, + extraArgs: ["--sync-comments-only", "--item-numbers", "74480"], + }); + }); + + const report = readFileSync(itemPath, "utf8"); + assert.match(report, /^plugin_sdk_impact_check_sha256: [0-9a-f]{64}$/m); + assert.match(report, /^plugin_sdk_impact_check_synced_at: /m); + const calls = readFileSync(logPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const payloadCall = calls.find((call) => call[0] === "check-run-payload"); + assert.ok(payloadCall, "missing check-run payload"); + assert.equal(payloadCall[1].name, "Plugin SDK impact gate"); + assert.equal(payloadCall[1].head_sha, headSha); + assert.equal(payloadCall[1].conclusion, "success"); + assert.match(payloadCall[1].output.text, /How to clear this gate:/); + assert.match(payloadCall[1].output.text, /openclaw\/maintainer/); + assert.match(payloadCall[1].output.text, /src\/plugin-sdk\/runtime\.ts/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("apply-decisions refreshes recent PR comments after label sync adds justifications", () => { const root = mkdtempSync(tmpPrefix); try { @@ -16187,6 +16353,32 @@ test("ClawSweeper Plugin SDK impact labels remove stale owned labels and preserv ); }); +test("ClawSweeper Plugin SDK impact check explains blocked maintainer and RFC gates", () => { + const blocked = pluginSdkImpactCheckPayloadForTest({ + classification: "plugin-sdk:architecture-change", + reason: "Plugin SDK architecture changed.", + source: "existing-label", + triggeredPaths: ["src/plugin-sdk/index.ts"], + }); + + assert.equal(blocked.conclusion, "failure"); + assert.equal(blocked.output.title, "Plugin SDK impact gate blocked"); + assert.match(blocked.output.text, /Maintainer approval: required and missing/); + assert.match(blocked.output.text, /RFC: required and missing/); + assert.match(blocked.output.text, /How to clear this gate:/); + assert.match(blocked.output.text, /link a merged openclaw\/rfcs PR/); + + const satisfied = pluginSdkImpactCheckPayloadForTest({ + classification: "plugin-sdk:architecture-change", + maintainerApprovers: ["alice"], + mergedRfcPulls: [7], + }); + + assert.equal(satisfied.conclusion, "success"); + assert.match(satisfied.output.text, /Maintainer approval: satisfied by @alice/); + assert.match(satisfied.output.text, /RFC: satisfied by openclaw\/rfcs#7/); +}); + test("ClawSweeper classifies Plugin SDK impact from PR files", () => { assert.equal( pluginSdkImpactFromPullFilesForTest({ @@ -17292,6 +17484,8 @@ test("sweep target write tokens can merge pull requests", () => { assert.equal(targetWriteTokenBlocks.length, 3); for (const block of targetWriteTokenBlocks) { assert.match(block, /permission-contents: write/); + assert.match(block, /permission-checks: write/); + assert.match(block, /permission-members: read/); assert.match(block, /permission-pull-requests: write/); } });