mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(ci): scope PR merge diff checks to first parent (#90287)
Summary: - This PR adds opt-in first-parent merge-head diff-base handling for CI changed-scope, changed-lanes, and OpenGrep PR scans, plus synthetic merge coverage and small lint/type cleanups. - PR surface: Source +6, Tests +204, Config +1, Other +179. Total +390 across 15 files. - Reproducibility: yes. The synthetic merge tests and PR body live-ref proof show the stale payload-base path can include main-only files, and first-parent mode narrows it to PR-owned paths. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(ci): update workflow guard expectations - PR branch already contained follow-up commit before automerge: fix(ci): resolve plugin guardrail lint failures - PR branch already contained follow-up commit before automerge: fix(ci): preserve plugin run context typing - PR branch already contained follow-up commit before automerge: fix(ci): scope PR merge diff checks to first parent Validation: - ClawSweeper review passed for head40235e8c3d. - Required merge gates passed before the squash merge. Prepared head SHA:40235e8c3dReview: https://github.com/openclaw/openclaw/pull/90287#issuecomment-4621155576 Co-authored-by: Mason Huang <masonxhuang@tencent.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: hxy91819 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
This commit is contained in:
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
@@ -146,12 +146,12 @@ jobs:
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD --merge-head-first-parent
|
||||
fi
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
- name: Build CI manifest
|
||||
id: manifest
|
||||
env:
|
||||
|
||||
3
.github/workflows/opengrep-precise.yml
vendored
3
.github/workflows/opengrep-precise.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 2
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
@@ -74,6 +74,7 @@ jobs:
|
||||
- name: Run opengrep on PR diff
|
||||
env:
|
||||
OPENCLAW_OPENGREP_BASE_REF: ${{ github.event.pull_request.base.sha }}...HEAD
|
||||
OPENCLAW_OPENGREP_MERGE_HEAD_FIRST_PARENT: "1"
|
||||
# Findings from precise rules block this workflow. Pull requests scan
|
||||
# changed first-party source paths only so findings stay attributable to
|
||||
# the PR diff. Test/fixture/QA path exclusions live in `.semgrepignore`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
|
||||
|
||||
const GIT_OUTPUT_MAX_BUFFER = 64 * 1024 * 1024;
|
||||
const IMPLAUSIBLE_NO_MERGE_BASE_DIFF_PATHS = 200;
|
||||
@@ -213,13 +214,21 @@ export function detectChangedLanes(changedPaths, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ paths: string[]; base: string; head?: string; staged?: boolean }} params
|
||||
* @param {{ paths: string[]; base: string; head?: string; staged?: boolean; mergeHeadFirstParent?: boolean }} params
|
||||
* @returns {ChangedLaneResult}
|
||||
*/
|
||||
export function detectChangedLanesForPaths(params) {
|
||||
const base = params.staged
|
||||
? params.base
|
||||
: resolveMergeHeadDiffBase({
|
||||
base: params.base,
|
||||
head: params.head ?? "HEAD",
|
||||
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
||||
preferFirstParent: params.mergeHeadFirstParent === true,
|
||||
});
|
||||
const packageJsonChangeKind = params.paths.includes("package.json")
|
||||
? classifyPackageJsonChangeFromGit({
|
||||
base: params.base,
|
||||
base,
|
||||
head: params.head,
|
||||
staged: params.staged,
|
||||
})
|
||||
@@ -228,13 +237,19 @@ export function detectChangedLanesForPaths(params) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ base: string; head?: string; includeWorktree?: boolean; cwd?: string }} params
|
||||
* @param {{ base: string; head?: string; includeWorktree?: boolean; cwd?: string; mergeHeadFirstParent?: boolean }} params
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function listChangedPathsFromGit(params) {
|
||||
const base = params.base;
|
||||
const head = params.head ?? "HEAD";
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const base = resolveMergeHeadDiffBase({
|
||||
base: params.base,
|
||||
head,
|
||||
cwd,
|
||||
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
||||
preferFirstParent: params.mergeHeadFirstParent === true,
|
||||
});
|
||||
if (!base) {
|
||||
return [];
|
||||
}
|
||||
@@ -453,6 +468,7 @@ function parseArgs(argv) {
|
||||
base: "origin/main",
|
||||
head: "HEAD",
|
||||
staged: false,
|
||||
mergeHeadFirstParent: false,
|
||||
json: false,
|
||||
githubOutput: false,
|
||||
help: false,
|
||||
@@ -465,6 +481,7 @@ function parseArgs(argv) {
|
||||
stringFlag("--base", "base"),
|
||||
stringFlag("--head", "head"),
|
||||
booleanFlag("--staged", "staged"),
|
||||
booleanFlag("--merge-head-first-parent", "mergeHeadFirstParent"),
|
||||
booleanFlag("--json", "json"),
|
||||
booleanFlag("--github-output", "githubOutput"),
|
||||
booleanFlag("--help", "help"),
|
||||
@@ -538,12 +555,17 @@ if (isDirectRun()) {
|
||||
? args.paths
|
||||
: args.staged
|
||||
? listStagedChangedPaths()
|
||||
: listChangedPathsFromGit({ base: args.base, head: args.head });
|
||||
: listChangedPathsFromGit({
|
||||
base: args.base,
|
||||
head: args.head,
|
||||
mergeHeadFirstParent: args.mergeHeadFirstParent,
|
||||
});
|
||||
const result = detectChangedLanesForPaths({
|
||||
paths,
|
||||
base: args.base,
|
||||
head: args.head,
|
||||
staged: args.staged,
|
||||
mergeHeadFirstParent: args.mergeHeadFirstParent,
|
||||
});
|
||||
if (args.githubOutput) {
|
||||
writeChangedLaneGitHubOutput(result);
|
||||
|
||||
@@ -15,7 +15,12 @@ export type InstallSmokeScope = {
|
||||
|
||||
export function detectChangedScope(changedPaths: string[]): ChangedScope;
|
||||
export function detectInstallSmokeScope(changedPaths: string[]): InstallSmokeScope;
|
||||
export function listChangedPaths(base: string, head?: string): string[];
|
||||
export function listChangedPaths(
|
||||
base: string,
|
||||
head?: string,
|
||||
cwd?: string,
|
||||
preferMergeHeadFirstParent?: boolean,
|
||||
): string[];
|
||||
export function writeGitHubOutput(
|
||||
scope: ChangedScope,
|
||||
outputPath?: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
||||
import { resolveMergeHeadDiffBase } from "./lib/merge-head-diff-base.mjs";
|
||||
|
||||
/** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean; runChangedSmoke: boolean; runControlUiI18n: boolean }} ChangedScope */
|
||||
/** @typedef {{ runFastOnly: boolean; runPluginContracts: boolean; runCiRouting: boolean }} NodeFastScope */
|
||||
@@ -228,13 +229,26 @@ export function detectInstallSmokeScope(changedPaths) {
|
||||
/**
|
||||
* @param {string} base
|
||||
* @param {string} [head]
|
||||
* @param {string} [cwd]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function listChangedPaths(base, head = "HEAD") {
|
||||
export function listChangedPaths(
|
||||
base,
|
||||
head = "HEAD",
|
||||
cwd = process.cwd(),
|
||||
preferMergeHeadFirstParent = false,
|
||||
) {
|
||||
if (!base) {
|
||||
return [];
|
||||
}
|
||||
const output = execFileSync("git", ["diff", "--name-only", base, head], {
|
||||
const diffBase = resolveMergeHeadDiffBase({
|
||||
base,
|
||||
head,
|
||||
cwd,
|
||||
preferFirstParent: preferMergeHeadFirstParent,
|
||||
});
|
||||
const output = execFileSync("git", ["diff", "--name-only", diffBase, head], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
});
|
||||
@@ -293,7 +307,7 @@ function isDirectRun() {
|
||||
|
||||
/** @param {string[]} argv */
|
||||
function parseArgs(argv) {
|
||||
const args = { base: "", head: "HEAD" };
|
||||
const args = { base: "", head: "HEAD", mergeHeadFirstParent: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--base") {
|
||||
args.base = argv[i + 1] ?? "";
|
||||
@@ -303,6 +317,10 @@ function parseArgs(argv) {
|
||||
if (argv[i] === "--head") {
|
||||
args.head = argv[i + 1] ?? "HEAD";
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (argv[i] === "--merge-head-first-parent") {
|
||||
args.mergeHeadFirstParent = true;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
@@ -311,7 +329,12 @@ function parseArgs(argv) {
|
||||
if (isDirectRun()) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
try {
|
||||
const changedPaths = listChangedPaths(args.base, args.head);
|
||||
const changedPaths = listChangedPaths(
|
||||
args.base,
|
||||
args.head,
|
||||
process.cwd(),
|
||||
args.mergeHeadFirstParent,
|
||||
);
|
||||
if (changedPaths.length === 0) {
|
||||
writeGitHubOutput(EMPTY_SCOPE);
|
||||
process.exit(0);
|
||||
|
||||
95
scripts/lib/merge-head-diff-base.mjs
Normal file
95
scripts/lib/merge-head-diff-base.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_GIT_OUTPUT_MAX_BUFFER = 16 * 1024 * 1024;
|
||||
|
||||
export function resolveMergeHeadDiffBase({
|
||||
base,
|
||||
head = "HEAD",
|
||||
cwd = process.cwd(),
|
||||
maxBuffer = DEFAULT_GIT_OUTPUT_MAX_BUFFER,
|
||||
preferFirstParent = false,
|
||||
}) {
|
||||
if (!base) {
|
||||
return "";
|
||||
}
|
||||
if (!preferFirstParent) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const parents = listCommitParents({ ref: head, cwd, maxBuffer });
|
||||
if (parents.length < 2) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const firstParent = resolveCommit({ ref: parents[0], cwd, maxBuffer });
|
||||
const explicitBase = resolveCommit({ ref: base, cwd, maxBuffer });
|
||||
if (!firstParent || firstParent === explicitBase) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return firstParent;
|
||||
}
|
||||
|
||||
function listCommitParents({ ref, cwd, maxBuffer }) {
|
||||
try {
|
||||
const output = execFileSync("git", ["rev-list", "--parents", "-n", "1", ref], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
maxBuffer,
|
||||
}).trim();
|
||||
return output.split(/\s+/u).slice(1);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCommit({ ref, cwd, maxBuffer }) {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
encoding: "utf8",
|
||||
maxBuffer,
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
base: "",
|
||||
head: "HEAD",
|
||||
preferFirstParent: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--base") {
|
||||
args.base = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--head") {
|
||||
args.head = argv[index + 1] ?? "HEAD";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--prefer-first-parent") {
|
||||
args.preferFirstParent = true;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
process.stdout.write(
|
||||
`${resolveMergeHeadDiffBase({
|
||||
base: args.base,
|
||||
head: args.head,
|
||||
preferFirstParent: args.preferFirstParent,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
@@ -109,9 +109,43 @@ if (( CHANGED_ONLY && PATHS_PASSED )); then
|
||||
exit 64
|
||||
fi
|
||||
|
||||
resolve_changed_diff_ref() {
|
||||
local diff_ref="${OPENCLAW_OPENGREP_BASE_REF:-origin/main...HEAD}"
|
||||
local base_ref
|
||||
local head_ref
|
||||
local resolved_base
|
||||
|
||||
if [[ "$diff_ref" != *"..."* ]]; then
|
||||
printf '%s\n' "$diff_ref"
|
||||
return 0
|
||||
fi
|
||||
if [[ "${OPENCLAW_OPENGREP_MERGE_HEAD_FIRST_PARENT:-0}" != "1" ]]; then
|
||||
printf '%s\n' "$diff_ref"
|
||||
return 0
|
||||
fi
|
||||
|
||||
base_ref="${diff_ref%%...*}"
|
||||
head_ref="${diff_ref#*...}"
|
||||
# First-parent resolution is shared with the Node CI routers so PR diff
|
||||
# scope cannot drift between changed-lanes, changed-scope, and OpenGrep.
|
||||
resolved_base="$(
|
||||
node "$REPO_ROOT/scripts/lib/merge-head-diff-base.mjs" \
|
||||
--base "$base_ref" \
|
||||
--head "$head_ref" \
|
||||
--prefer-first-parent 2>/dev/null || true
|
||||
)"
|
||||
if [[ -z "$resolved_base" || "$resolved_base" == "$base_ref" ]]; then
|
||||
printf '%s\n' "$diff_ref"
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s...%s\n' "$resolved_base" "$head_ref"
|
||||
}
|
||||
|
||||
# Default scan paths match CI. Override by passing `-- <paths...>`.
|
||||
if (( PATHS_PASSED == 0 )); then
|
||||
if (( CHANGED_ONLY )); then
|
||||
CHANGED_DIFF_REF="$(resolve_changed_diff_ref)"
|
||||
SCAN_PATHS=()
|
||||
while IFS= read -r path; do
|
||||
# OpenGrep errors when an explicit changed path is a symlink; scan the
|
||||
@@ -125,7 +159,7 @@ if (( PATHS_PASSED == 0 )); then
|
||||
SCAN_PATHS+=( "$path" )
|
||||
done < <(
|
||||
{
|
||||
git diff --name-only --diff-filter=ACMRTUXB "${OPENCLAW_OPENGREP_BASE_REF:-origin/main...HEAD}" 2>/dev/null || true
|
||||
git diff --name-only --diff-filter=ACMRTUXB "$CHANGED_DIFF_REF" 2>/dev/null || true
|
||||
git diff --name-only --diff-filter=ACMRTUXB -- 2>/dev/null || true
|
||||
git ls-files --others --exclude-standard
|
||||
} | awk '/^(src|extensions|apps|packages|scripts)\// { print }' | sort -u
|
||||
@@ -135,7 +169,7 @@ if (( PATHS_PASSED == 0 )); then
|
||||
RULEPACK_CHANGED_PATHS+=( "$path" )
|
||||
done < <(
|
||||
{
|
||||
git diff --name-only --diff-filter=ACMRTUXB "${OPENCLAW_OPENGREP_BASE_REF:-origin/main...HEAD}" 2>/dev/null || true
|
||||
git diff --name-only --diff-filter=ACMRTUXB "$CHANGED_DIFF_REF" 2>/dev/null || true
|
||||
git diff --name-only --diff-filter=ACMRTUXB -- 2>/dev/null || true
|
||||
git ls-files --others --exclude-standard
|
||||
} | awk '/^(security\/opengrep\/|scripts\/run-opengrep\.sh$|\.semgrepignore$|\.github\/workflows\/opengrep-)/ { print }' | sort -u
|
||||
|
||||
@@ -256,5 +256,5 @@ export function promptMigrationSkillSelectionValues(
|
||||
return prompt.prompt();
|
||||
}
|
||||
|
||||
/** Back-compat alias for plugin selection prompts that share the same picker. */
|
||||
/** Compatibility alias for plugin selection prompts that share the same picker. */
|
||||
export const promptMigrationSelectionValues = promptMigrationSkillSelectionValues;
|
||||
|
||||
@@ -515,8 +515,10 @@ function collectDeprecatedTestBarrelImports(): string[] {
|
||||
function collectDeprecatedPackageTestingBridgeDrift(): string[] {
|
||||
const source = fs
|
||||
.readFileSync(resolve(REPO_ROOT, "packages/plugin-sdk/src/testing.ts"), "utf8")
|
||||
.trim();
|
||||
return source === 'export * from "../../../src/plugin-sdk/testing.js";'
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("//"));
|
||||
return source.length === 1 && source[0] === 'export * from "../../../src/plugin-sdk/testing.js";'
|
||||
? []
|
||||
: ["packages/plugin-sdk/src/testing.ts"];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { withPluginHostCleanupTimeout } from "./host-hook-cleanup-timeout.js";
|
||||
import {
|
||||
isPluginJsonValue,
|
||||
type PluginAgentEventSubscriptionRegistration,
|
||||
type PluginHostCleanupReason,
|
||||
type PluginJsonValue,
|
||||
type PluginRunContextGetParams,
|
||||
@@ -17,6 +18,9 @@ import type { PluginRegistry } from "./registry-types.js";
|
||||
|
||||
type PluginRunContextNamespaces = Map<string, PluginJsonValue>;
|
||||
type PluginRunContextByPlugin = Map<string, PluginRunContextNamespaces>;
|
||||
type PluginAgentEventSubscriptionContext = Parameters<
|
||||
PluginAgentEventSubscriptionRegistration["handle"]
|
||||
>[1];
|
||||
|
||||
type SchedulerJobRecord = {
|
||||
pluginId: string;
|
||||
@@ -305,10 +309,12 @@ export function dispatchPluginAgentEventSubscriptions(params: {
|
||||
const pluginId = registration.pluginId;
|
||||
const runId = params.event.runId;
|
||||
let handlerActive = true;
|
||||
const ctx = {
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Run-context JSON reads are caller-typed by namespace.
|
||||
getRunContext: <T extends PluginJsonValue = PluginJsonValue>(namespace: string) =>
|
||||
getPluginRunContext({ pluginId, get: { runId, namespace } }) as T | undefined,
|
||||
const ctx: PluginAgentEventSubscriptionContext = {
|
||||
getRunContext: ((namespace: string) =>
|
||||
getPluginRunContext({
|
||||
pluginId,
|
||||
get: { runId, namespace },
|
||||
})) as PluginAgentEventSubscriptionContext["getRunContext"],
|
||||
setRunContext: (namespace: string, value: PluginJsonValue) => {
|
||||
setPluginRunContext({
|
||||
pluginId,
|
||||
|
||||
@@ -25,7 +25,12 @@ const { detectChangedScope, detectInstallSmokeScope, detectNodeFastScope, listCh
|
||||
runPluginContracts: boolean;
|
||||
runCiRouting: boolean;
|
||||
};
|
||||
listChangedPaths: (base: string, head?: string) => string[];
|
||||
listChangedPaths: (
|
||||
base: string,
|
||||
head?: string,
|
||||
cwd?: string,
|
||||
preferMergeHeadFirstParent?: boolean,
|
||||
) => string[];
|
||||
};
|
||||
|
||||
const markerPaths: string[] = [];
|
||||
@@ -56,6 +61,42 @@ function parseGitHubOutput(output: string): Record<string, string> {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function git(repoDir: string, args: string[]): string {
|
||||
return execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function writeRepoFile(repoDir: string, filePath: string, contents: string): void {
|
||||
const absolutePath = path.join(repoDir, filePath);
|
||||
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
||||
fs.writeFileSync(absolutePath, contents, "utf8");
|
||||
}
|
||||
|
||||
function createSyntheticMergeRepo(prefix: string): { repoDir: string; staleBase: string } {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(repoDir);
|
||||
|
||||
git(repoDir, ["init", "-b", "main"]);
|
||||
git(repoDir, ["config", "user.email", "ci@example.invalid"]);
|
||||
git(repoDir, ["config", "user.name", "CI"]);
|
||||
writeRepoFile(repoDir, "README.md", "base\n");
|
||||
git(repoDir, ["add", "."]);
|
||||
git(repoDir, ["commit", "-m", "base"]);
|
||||
const staleBase = git(repoDir, ["rev-parse", "HEAD"]);
|
||||
|
||||
git(repoDir, ["switch", "-c", "feature"]);
|
||||
writeRepoFile(repoDir, "src/pr.ts", "export const pr = true;\n");
|
||||
git(repoDir, ["add", "."]);
|
||||
git(repoDir, ["commit", "-m", "feature"]);
|
||||
|
||||
git(repoDir, ["switch", "main"]);
|
||||
writeRepoFile(repoDir, "src/main-only.ts", "export const mainOnly = true;\n");
|
||||
git(repoDir, ["add", "."]);
|
||||
git(repoDir, ["commit", "-m", "main only"]);
|
||||
git(repoDir, ["merge", "--no-ff", "feature", "-m", "synthetic merge"]);
|
||||
|
||||
return { repoDir, staleBase };
|
||||
}
|
||||
|
||||
describe("detectChangedScope", () => {
|
||||
it("fails safe when no paths are provided", () => {
|
||||
expect(detectChangedScope([])).toEqual({
|
||||
@@ -652,6 +693,22 @@ describe("detectChangedScope", () => {
|
||||
expect(fs.existsSync(markerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the merge commit first parent instead of a stale PR payload base", () => {
|
||||
const { repoDir, staleBase } = createSyntheticMergeRepo("openclaw-ci-scope-merge-");
|
||||
|
||||
expect(
|
||||
execFileSync("git", ["diff", "--name-only", staleBase, "HEAD"], {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
})
|
||||
.trim()
|
||||
.split("\n")
|
||||
.toSorted(),
|
||||
).toEqual(["src/main-only.ts", "src/pr.ts"]);
|
||||
|
||||
expect(listChangedPaths(staleBase, "HEAD", repoDir, true)).toEqual(["src/pr.ts"]);
|
||||
});
|
||||
|
||||
it("keeps direct CLI preflight empty diffs as no-op scope", () => {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ci-scope-empty-"));
|
||||
tempDirs.push(repoDir);
|
||||
|
||||
@@ -71,6 +71,71 @@ function parseChangedLaneOutput(output: string): {
|
||||
};
|
||||
}
|
||||
|
||||
function writeRepoFile(repoDir: string, filePath: string, contents: string): void {
|
||||
const absolutePath = path.join(repoDir, filePath);
|
||||
mkdirSync(path.dirname(absolutePath), { recursive: true });
|
||||
writeFileSync(absolutePath, contents, "utf8");
|
||||
}
|
||||
|
||||
function createSyntheticMergeRepo(prefix: string): { dir: string; staleBase: string } {
|
||||
const dir = makeTempRepoRoot(tempDirs, prefix);
|
||||
git(dir, ["init", "-q", "--initial-branch=main"]);
|
||||
writeRepoFile(dir, "README.md", "base\n");
|
||||
git(dir, ["add", "."]);
|
||||
git(dir, [
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"user.name=Test User",
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
"base",
|
||||
]);
|
||||
const staleBase = git(dir, ["rev-parse", "HEAD"]);
|
||||
|
||||
git(dir, ["switch", "-q", "-c", "feature"]);
|
||||
writeRepoFile(dir, "src/pr.ts", "export const pr = true;\n");
|
||||
git(dir, ["add", "."]);
|
||||
git(dir, [
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"user.name=Test User",
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
"feature",
|
||||
]);
|
||||
|
||||
git(dir, ["switch", "-q", "main"]);
|
||||
writeRepoFile(dir, "src/main-only.ts", "export const mainOnly = true;\n");
|
||||
git(dir, ["add", "."]);
|
||||
git(dir, [
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"user.name=Test User",
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
"main only",
|
||||
]);
|
||||
git(dir, [
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"-c",
|
||||
"user.name=Test User",
|
||||
"merge",
|
||||
"--no-ff",
|
||||
"feature",
|
||||
"-m",
|
||||
"synthetic merge",
|
||||
]);
|
||||
|
||||
return { dir, staleBase };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupCorepackPnpmShimDir();
|
||||
cleanupTempDirs(tempDirs);
|
||||
@@ -251,6 +316,23 @@ describe("scripts/changed-lanes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the merge commit first parent instead of a stale PR payload base", () => {
|
||||
const { dir, staleBase } = createSyntheticMergeRepo("openclaw-changed-lanes-merge-");
|
||||
|
||||
expect(listChangedPathsFromGit({ base: staleBase, cwd: dir, includeWorktree: false })).toEqual([
|
||||
"src/main-only.ts",
|
||||
"src/pr.ts",
|
||||
]);
|
||||
expect(
|
||||
listChangedPathsFromGit({
|
||||
base: staleBase,
|
||||
cwd: dir,
|
||||
includeWorktree: false,
|
||||
mergeHeadFirstParent: true,
|
||||
}),
|
||||
).toEqual(["src/pr.ts"]);
|
||||
});
|
||||
|
||||
it("ignores local Crabbox metadata in the default local diff", () => {
|
||||
const dir = makeTempRepoRoot(tempDirs, "openclaw-changed-lanes-crabbox-");
|
||||
git(dir, ["init", "-q", "--initial-branch=main"]);
|
||||
|
||||
@@ -59,8 +59,9 @@ describe("ci workflow guards", () => {
|
||||
expect(checkoutStep.run, jobName).toContain("timed out on attempt $attempt; retrying");
|
||||
expect(checkoutStep.run, jobName).not.toContain("if timeout --signal=TERM");
|
||||
expect(checkoutStep.run, jobName).toContain("-c protocol.version=2");
|
||||
const expectedDepth = jobName === "preflight" ? 2 : 1;
|
||||
expect(checkoutStep.run, jobName).toContain(
|
||||
"fetch --no-tags --prune --no-recurse-submodules --depth=1 origin",
|
||||
`fetch --no-tags --prune --no-recurse-submodules --depth=${expectedDepth} origin`,
|
||||
);
|
||||
if (jobName !== "skills-python") {
|
||||
expect(checkoutStep.run, jobName).toContain('if [ "$fetch_status" = "124" ]');
|
||||
|
||||
@@ -214,7 +214,6 @@ describe("production lint suppressions", () => {
|
||||
"src/plugin-sdk/test-helpers/public-surface-loader.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugin-sdk/test-helpers/subagent-hooks.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/hooks.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/host-hook-runtime.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/host-hook-state.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/host-hooks.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
"src/plugins/lazy-service-module.ts|typescript/no-unnecessary-type-parameters|1",
|
||||
|
||||
@@ -15,6 +15,17 @@ function writeFile(filePath: string, content: string): void {
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function copyRunOpengrepFiles(repo: string): void {
|
||||
const scriptSource = path.resolve("scripts/run-opengrep.sh");
|
||||
const helperSource = path.resolve("scripts/lib/merge-head-diff-base.mjs");
|
||||
writeFile(path.join(repo, "scripts/run-opengrep.sh"), fs.readFileSync(scriptSource, "utf8"));
|
||||
writeFile(
|
||||
path.join(repo, "scripts/lib/merge-head-diff-base.mjs"),
|
||||
fs.readFileSync(helperSource, "utf8"),
|
||||
);
|
||||
fs.chmodSync(path.join(repo, "scripts/run-opengrep.sh"), 0o755);
|
||||
}
|
||||
|
||||
describe("run-opengrep.sh", () => {
|
||||
it("validates the rulepack when only OpenGrep rulepack files changed", () => {
|
||||
const repo = createTempDir("openclaw-run-opengrep-");
|
||||
@@ -22,9 +33,7 @@ describe("run-opengrep.sh", () => {
|
||||
git(repo, "config", "user.email", "test@example.com");
|
||||
git(repo, "config", "user.name", "Test User");
|
||||
|
||||
const scriptSource = path.resolve("scripts/run-opengrep.sh");
|
||||
writeFile(path.join(repo, "scripts/run-opengrep.sh"), fs.readFileSync(scriptSource, "utf8"));
|
||||
fs.chmodSync(path.join(repo, "scripts/run-opengrep.sh"), 0o755);
|
||||
copyRunOpengrepFiles(repo);
|
||||
writeFile(path.join(repo, "security/opengrep/precise.yml"), "rules: []\n");
|
||||
git(repo, "add", ".");
|
||||
git(repo, "commit", "-qm", "initial");
|
||||
@@ -57,4 +66,58 @@ describe("run-opengrep.sh", () => {
|
||||
const args = fs.readFileSync(path.join(repo, "opengrep-args.txt"), "utf8");
|
||||
expect(args).toContain("security/opengrep/precise.yml");
|
||||
});
|
||||
|
||||
it("scans PR files instead of main-only files when the payload base is stale", () => {
|
||||
const repo = createTempDir("openclaw-run-opengrep-merge-");
|
||||
git(repo, "init", "-q", "--initial-branch=main");
|
||||
git(repo, "config", "user.email", "test@example.com");
|
||||
git(repo, "config", "user.name", "Test User");
|
||||
|
||||
copyRunOpengrepFiles(repo);
|
||||
writeFile(path.join(repo, "security/opengrep/precise.yml"), "rules: []\n");
|
||||
writeFile(path.join(repo, "README.md"), "base\n");
|
||||
git(repo, "add", ".");
|
||||
git(repo, "commit", "-qm", "base");
|
||||
const staleBase = git(repo, "rev-parse", "HEAD");
|
||||
|
||||
git(repo, "switch", "-q", "-c", "feature");
|
||||
writeFile(path.join(repo, "src/pr.ts"), "export const pr = true;\n");
|
||||
git(repo, "add", ".");
|
||||
git(repo, "commit", "-qm", "feature");
|
||||
|
||||
git(repo, "switch", "-q", "main");
|
||||
writeFile(path.join(repo, "src/main-only.ts"), "export const mainOnly = true;\n");
|
||||
git(repo, "add", ".");
|
||||
git(repo, "commit", "-qm", "main only");
|
||||
git(repo, "merge", "--no-ff", "feature", "-m", "synthetic merge");
|
||||
|
||||
const argsPath = path.join(repo, "opengrep-args.txt");
|
||||
const binDir = path.join(repo, "bin");
|
||||
fs.mkdirSync(binDir);
|
||||
writeFile(
|
||||
path.join(binDir, "opengrep"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
`printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`,
|
||||
"exit 0",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
fs.chmodSync(path.join(binDir, "opengrep"), 0o755);
|
||||
|
||||
execFileSync("bash", ["scripts/run-opengrep.sh", "--changed"], {
|
||||
cwd: repo,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
OPENCLAW_OPENGREP_BASE_REF: `${staleBase}...HEAD`,
|
||||
OPENCLAW_OPENGREP_MERGE_HEAD_FIRST_PARENT: "1",
|
||||
},
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const args = fs.readFileSync(argsPath, "utf8");
|
||||
expect(args).toContain("src/pr.ts");
|
||||
expect(args).not.toContain("src/main-only.ts");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user