diff --git a/CHANGELOG.md b/CHANGELOG.md index 573151a17..fcc4845c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -313,6 +313,7 @@ See [docs/releases/v0.0.7.md](docs/releases/v0.0.7.md) for full notes and [docs/ ### Removed - None. +- Add private local maintainer profiles for PR Review so OK Code can load external maintainer workflows without committing `.okcode/` files to the target repo. ## [0.0.6] - 2026-03-28 diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6b859dc47..41e9248fa 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1705,6 +1705,49 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect( + "resets upstream to the current branch when stale tracking points at a different branch", + () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(tmp); + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(tmp, ["remote", "add", "origin", remote]); + yield* git(tmp, ["push", "-u", "origin", initialBranch]); + + const staleBranch = "codex/update-create-markdown-2-0-1"; + yield* git(tmp, ["checkout", "-b", staleBranch]); + yield* writeTextFile(path.join(tmp, "stale.txt"), "stale\n"); + yield* git(tmp, ["add", "stale.txt"]); + yield* git(tmp, ["commit", "-m", "stale branch"]); + yield* git(tmp, ["push", "-u", "origin", staleBranch]); + + const featureBranch = "fresh-auth-flow"; + yield* git(tmp, ["checkout", initialBranch]); + yield* git(tmp, ["checkout", "-b", featureBranch]); + yield* writeTextFile(path.join(tmp, "fresh.txt"), "fresh\n"); + yield* git(tmp, ["add", "fresh.txt"]); + yield* git(tmp, ["commit", "-m", "fresh branch"]); + yield* git(tmp, ["push", "-u", "origin", featureBranch]); + yield* git(tmp, ["branch", "--set-upstream-to", `origin/${staleBranch}`]); + + const core = yield* GitCore; + const pushed = yield* core.pushCurrentBranch(tmp, null); + + expect(pushed.status).toBe("pushed"); + expect(pushed.setUpstream).toBe(true); + expect(pushed.upstreamBranch).toBe(`origin/${featureBranch}`); + expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + `origin/${featureBranch}`, + ); + }), + ); + it.effect("includes command context when worktree removal fails", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index c5e084c17..c521fd076 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1433,16 +1433,34 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" Effect.catch(() => Effect.succeed(null)), ); if (currentUpstream) { - yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ + if ( + currentUpstream.upstreamBranch === branch || + currentUpstream.remoteName !== "origin" + ) { + yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ + "push", + currentUpstream.remoteName, + `HEAD:${currentUpstream.upstreamBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: currentUpstream.upstreamRef, + setUpstream: false, + }; + } + + yield* runGit("GitCore.pushCurrentBranch.pushResetUpstream", cwd, [ "push", + "-u", currentUpstream.remoteName, - `HEAD:${currentUpstream.upstreamBranch}`, + branch, ]); return { status: "pushed" as const, branch, - upstreamBranch: currentUpstream.upstreamRef, - setUpstream: false, + upstreamBranch: `${currentUpstream.remoteName}/${branch}`, + setUpstream: true, }; } diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 79acc11ed..3c329acce 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -1427,6 +1427,74 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("prefers the current local branch for head and ignores stale PR base metadata", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("okcode-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + yield* runGit(repoDir, ["checkout", "-b", "codex/update-create-markdown-2-0-1"]); + fs.writeFileSync(path.join(repoDir, "old.txt"), "old\n"); + yield* runGit(repoDir, ["add", "old.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Old branch commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "codex/update-create-markdown-2-0-1"]); + + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "fresh-auth-flow"]); + fs.writeFileSync(path.join(repoDir, "fresh.txt"), "fresh\n"); + yield* runGit(repoDir, ["add", "fresh.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fresh branch commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "fresh-auth-flow"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to", + "origin/codex/update-create-markdown-2-0-1", + ]); + yield* runGit(repoDir, [ + "config", + "branch.fresh-auth-flow.gh-merge-base", + "codex/update-create-markdown-2-0-1", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([ + { + number: 233, + title: "Fresh auth flow", + url: "https://github.com/pingdotgg/codething-mvp/pull/233", + baseRefName: "main", + headRefName: "fresh-auth-flow", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(233); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head fresh-auth-flow")), + ).toBe(true); + expect( + ghCalls.some((call) => + call.includes( + "pr create --base codex/update-create-markdown-2-0-1 --head codex/update-create-markdown-2-0-1", + ), + ), + ).toBe(false); + }), + ); + it.effect("rejects push/pr actions from detached HEAD", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("okcode-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 59ad1e3f0..778aaae50 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -519,9 +519,6 @@ export const makeGitManager = Effect.gen(function* () { const headBranchFromUpstream = details.upstreamRef ? extractBranchFromRef(details.upstreamRef) : ""; - const headBranch = - headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; - const [remoteRepository, originRepository] = yield* Effect.all( [ resolveRemoteRepositoryContext(cwd, remoteName), @@ -539,6 +536,11 @@ export const makeGitManager = Effect.gen(function* () { remoteName !== "origin" && remoteRepository.repositoryNameWithOwner !== null; + const headBranch = + isCrossRepository && headBranchFromUpstream.length > 0 + ? headBranchFromUpstream + : details.branch; + const ownerHeadSelector = remoteRepository.ownerLogin && headBranch.length > 0 ? `${remoteRepository.ownerLogin}:${headBranch}` @@ -557,6 +559,12 @@ export const makeGitManager = Effect.gen(function* () { ); } appendUnique(headSelectors, details.branch); + appendUnique( + headSelectors, + headBranchFromUpstream.length > 0 && headBranchFromUpstream !== details.branch + ? headBranchFromUpstream + : null, + ); appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { appendUnique(headSelectors, ownerHeadSelector); @@ -662,27 +670,31 @@ export const makeGitManager = Effect.gen(function* () { cwd: string, branch: string, upstreamRef: string | null, - headContext: Pick, + headContext: Pick, ) => Effect.gen(function* () { + const defaultFromGh = yield* gitHubCli + .getDefaultBranch({ cwd }) + .pipe(Effect.catch(() => Effect.succeed(null))); + const fallbackBase = defaultFromGh ?? "main"; const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); - if (configured) return configured; + if (configured && configured !== headContext.headBranch && configured === fallbackBase) { + return configured; + } if (upstreamRef && !headContext.isCrossRepository) { const upstreamBranch = extractBranchFromRef(upstreamRef); - if (upstreamBranch.length > 0 && upstreamBranch !== branch) { + if ( + upstreamBranch.length > 0 && + upstreamBranch !== branch && + upstreamBranch !== headContext.headBranch && + upstreamBranch === fallbackBase + ) { return upstreamBranch; } } - const defaultFromGh = yield* gitHubCli - .getDefaultBranch({ cwd }) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (defaultFromGh) { - return defaultFromGh; - } - - return "main"; + return fallbackBase; }); const resolvePreferredRemoteName = (cwd: string, branch: string) => @@ -1014,7 +1026,13 @@ export const makeGitManager = Effect.gen(function* () { }; } - const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); + let baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); + if (baseBranch === headContext.headBranch) { + const defaultBase = yield* gitHubCli + .getDefaultBranch({ cwd }) + .pipe(Effect.catch(() => Effect.succeed(null))); + baseBranch = defaultBase && defaultBase !== headContext.headBranch ? defaultBase : "main"; + } const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); const generated = yield* textGeneration.generatePrContent({ diff --git a/apps/server/src/prReview/Layers/PrReview.ts b/apps/server/src/prReview/Layers/PrReview.ts index dfbdec5bc..454109ce8 100644 --- a/apps/server/src/prReview/Layers/PrReview.ts +++ b/apps/server/src/prReview/Layers/PrReview.ts @@ -21,6 +21,8 @@ import type { PrWorkflowStepRunResult, } from "@okcode/contracts"; import { GitHubCli } from "../../git/Services/GitHubCli.ts"; +import { runProcess } from "../../processRunner"; +import { decodePrReviewLocalCommandAction } from "../localProfiles.ts"; import { RepoReviewConfig } from "../Services/RepoReviewConfig.ts"; import { PrReviewProjection } from "../Services/PrReviewProjection.ts"; import { WorkflowEngine } from "../Services/WorkflowEngine.ts"; @@ -592,6 +594,75 @@ const makePrReview = Effect.gen(function* () { }) => Effect.Effect, ): Effect.Effect => getRepoOwnerAndName(cwd).pipe(Effect.flatMap(f)); + const executeLocalCommandAction = (input: { + action: string; + prNumber: number; + step: { id: string; title: string; requiresConfirmation: boolean }; + }): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const decoded = decodePrReviewLocalCommandAction(input.action); + if (!decoded) { + return { + stepId: input.step.id, + status: "failed", + summary: `Workflow step ${input.step.title} is missing a runnable local command.`, + requiresConfirmation: input.step.requiresConfirmation, + } satisfies PrWorkflowStepRunResult; + } + + const args = decoded.args.map((entry) => + entry.replaceAll("{{prNumber}}", String(input.prNumber)), + ); + try { + const result = await runProcess(args[0] ?? decoded.label, args.slice(1), { + cwd: decoded.cwd, + timeoutMs: 10 * 60_000, + allowNonZeroExit: true, + maxBufferBytes: 512 * 1024, + outputMode: "truncate", + }); + const stderr = result.stderr.trim(); + const stdout = result.stdout.trim(); + const snippet = stderr || stdout; + if (result.code !== 0 || result.timedOut) { + return { + stepId: input.step.id, + status: "failed", + summary: + snippet.length > 0 + ? snippet.slice(0, 280) + : `${decoded.label} failed${result.timedOut ? " (timed out)" : ""}.`, + requiresConfirmation: input.step.requiresConfirmation, + } satisfies PrWorkflowStepRunResult; + } + return { + stepId: input.step.id, + status: "done", + summary: + snippet.length > 0 + ? snippet.slice(0, 280) + : `${decoded.label} completed successfully.`, + requiresConfirmation: input.step.requiresConfirmation, + } satisfies PrWorkflowStepRunResult; + } catch (error) { + return { + stepId: input.step.id, + status: "failed", + summary: + error instanceof Error ? error.message.slice(0, 280) : `${decoded.label} failed.`, + requiresConfirmation: input.step.requiresConfirmation, + } satisfies PrWorkflowStepRunResult; + } + }, + catch: (cause) => + new PrReviewError({ + operation: "runWorkflowStep", + detail: `Failed to execute workflow step ${input.step.title}.`, + cause, + }), + }); + const service: PrReviewShape = { getConfig: ({ cwd }) => repoReviewConfig.getConfig({ cwd }), watchRepoConfig: ({ cwd, onChange }) => repoReviewConfig.watchRepo({ cwd, onChange }), @@ -807,6 +878,18 @@ const makePrReview = Effect.gen(function* () { } else if (step.kind === "reviewAction") { status = "blocked"; summary = "Submit a review from the action rail to complete this step."; + } else if ( + config.source === "localProfile" && + typeof step.action === "string" && + decodePrReviewLocalCommandAction(step.action) + ) { + const commandResult = yield* executeLocalCommandAction({ + action: step.action, + prNumber: input.prNumber, + step, + }); + status = commandResult.status; + summary = commandResult.summary; } else if (step.kind === "skillSet" && step.skillSet) { summary = `Skill set ${step.skillSet} is ready to run.`; } diff --git a/apps/server/src/prReview/Layers/RepoReviewConfig.ts b/apps/server/src/prReview/Layers/RepoReviewConfig.ts index cc5243122..b4dcc1ee5 100644 --- a/apps/server/src/prReview/Layers/RepoReviewConfig.ts +++ b/apps/server/src/prReview/Layers/RepoReviewConfig.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { promises as fsPromises } from "node:fs"; @@ -14,12 +15,29 @@ import type { } from "@okcode/contracts"; import { Effect, Layer } from "effect"; import YAML from "yaml"; +import { runProcess } from "../../processRunner"; +import { + encodePrReviewLocalCommandAction, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, +} from "../localProfiles.ts"; import { RepoReviewConfig, type RepoReviewConfigShape } from "../Services/RepoReviewConfig.ts"; import { PrReviewConfigError } from "../Errors.ts"; const REVIEW_RULES_RELATIVE_PATH = ".okcode/review-rules.md"; const WORKFLOWS_RELATIVE_DIR = ".okcode/workflows"; const SKILL_SETS_RELATIVE_DIR = ".okcode/skill-sets"; +const LOCAL_PROFILE_RELATIVE_DIR = "pr-review-profiles"; + +type LocalProfileDefinition = { + id: string; + title: string; + body: string; + repositories: string[]; + adapter: "openclawMaintainer"; + maintainersRepo: string; + relativePath: string; + absolutePath: string; +}; const DEFAULT_BLOCKING_RULES: PrReviewRuleDefinition[] = [ { @@ -178,6 +196,7 @@ function normalizeRuleDefinitions(value: unknown): PrReviewRuleDefinition[] { function normalizeMentionGroups(value: unknown): PrReviewRules["mentionGroups"] { if (!Array.isArray(value)) return []; + const groups: Array = []; for (const [index, entry] of value.entries()) { if (!entry || typeof entry !== "object") continue; @@ -249,6 +268,76 @@ function normalizeWorkflowSteps(value: unknown): PrWorkflowStep[] { .filter((entry): entry is PrWorkflowStep => entry !== null); } +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); +} + +function normalizeRepositoryMatcher(value: string): string { + return value.trim().toLowerCase(); +} + +function resolveOkcodeHome(): string { + const raw = process.env.OKCODE_HOME?.trim(); + if (!raw) { + return path.join(os.homedir(), ".okcode"); + } + if (raw === "~") { + return os.homedir(); + } + if (raw.startsWith("~/") || raw.startsWith("~\\")) { + return path.join(os.homedir(), raw.slice(2)); + } + return path.resolve(raw); +} + +function resolveMaybeHomePath(input: string): string { + const trimmed = input.trim(); + if (trimmed === "~") { + return os.homedir(); + } + if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { + return path.join(os.homedir(), trimmed.slice(2)); + } + return path.resolve(trimmed); +} + +function readTitleAndDescription(input: { + raw: string | null; + fallbackTitle: string; + fallbackDescription?: string | null; +}): { title: string; description: string | null; body: string } { + if (!input.raw) { + return { + title: input.fallbackTitle, + description: input.fallbackDescription ?? null, + body: "", + }; + } + try { + const { frontmatter, body } = splitFrontmatter(input.raw); + const title = + typeof frontmatter.title === "string" && frontmatter.title.trim().length > 0 + ? frontmatter.title.trim() + : input.fallbackTitle; + const description = + typeof frontmatter.description === "string" && frontmatter.description.trim().length > 0 + ? frontmatter.description.trim() + : (input.fallbackDescription ?? null); + return { title, description, body }; + } catch { + return { + title: input.fallbackTitle, + description: input.fallbackDescription ?? null, + body: input.raw.trim(), + }; + } +} + function parseRulesDocument(input: ParsedFrontmatter): { rules: PrReviewRules; issues: PrReviewConfigIssue[]; @@ -410,6 +499,70 @@ function parseSkillSetDocument(input: ParsedFrontmatter): } } +function parseLocalProfileDocument(input: { absolutePath: string; raw: string }): { + profile: LocalProfileDefinition | null; + issues: PrReviewConfigIssue[]; +} { + const relativePath = path.join(LOCAL_PROFILE_RELATIVE_DIR, path.basename(input.absolutePath)); + try { + const { frontmatter, body } = splitFrontmatter(input.raw); + const repositories = normalizeStringArray(frontmatter.repositories).map( + normalizeRepositoryMatcher, + ); + const adapterRaw = typeof frontmatter.adapter === "string" ? frontmatter.adapter.trim() : ""; + const maintainersRepoRaw = + typeof frontmatter.maintainersRepo === "string" ? frontmatter.maintainersRepo.trim() : ""; + const id = + typeof frontmatter.id === "string" && frontmatter.id.trim().length > 0 + ? frontmatter.id.trim() + : path.basename(input.absolutePath, path.extname(input.absolutePath)); + const title = + typeof frontmatter.title === "string" && frontmatter.title.trim().length > 0 + ? frontmatter.title.trim() + : id; + + if (repositories.length === 0) { + return { + profile: null, + issues: [toIssue("warning", relativePath, "Profile is missing repositories[] matchers.")], + }; + } + if (adapterRaw !== "openclawMaintainer") { + return { + profile: null, + issues: [ + toIssue("warning", relativePath, `Unsupported adapter "${adapterRaw || "(missing)"}".`), + ], + }; + } + if (maintainersRepoRaw.length === 0) { + return { + profile: null, + issues: [toIssue("warning", relativePath, "Profile is missing maintainersRepo.")], + }; + } + + return { + profile: { + id, + title, + body, + repositories, + adapter: "openclawMaintainer", + maintainersRepo: resolveMaybeHomePath(maintainersRepoRaw), + relativePath, + absolutePath: input.absolutePath, + }, + issues: [], + }; + } catch (error) { + return { + profile: null, + issues: [toIssue("error", relativePath, `Failed to parse frontmatter: ${String(error)}`)], + }; + } +} + async function readMarkdownFile( cwd: string, relativePath: string, @@ -426,6 +579,20 @@ async function readMarkdownFile( } } +async function readMarkdownFileAbsolute( + absolutePath: string, +): Promise<{ exists: boolean; raw: string | null; absolutePath: string }> { + try { + const raw = await fsPromises.readFile(absolutePath, "utf8"); + return { exists: true, raw, absolutePath }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { exists: false, raw: null, absolutePath }; + } + throw error; + } +} + async function listMarkdownFiles(cwd: string, relativeDir: string): Promise { const absoluteDir = path.join(cwd, relativeDir); try { @@ -442,6 +609,292 @@ async function listMarkdownFiles(cwd: string, relativeDir: string): Promise { + try { + const entries = await fsPromises.readdir(absoluteDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md")) + .map((entry) => path.join(absoluteDir, entry.name)) + .toSorted((a, b) => a.localeCompare(b)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } +} + +async function determineGitHubRepositoryNameWithOwner(cwd: string): Promise { + const remote = await runProcess("git", ["remote", "get-url", "origin"], { + cwd, + timeoutMs: 5_000, + allowNonZeroExit: true, + }); + const remoteUrl = remote.stdout.trim() || remote.stderr.trim(); + return parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); +} + +async function buildOpenClawMaintainerConfig(input: { + profile: LocalProfileDefinition; + issues: PrReviewConfigIssue[]; +}): Promise { + const workflowPath = path.join(input.profile.maintainersRepo, ".agents/skills/PR_WORKFLOW.md"); + const reviewSkillPath = path.join( + input.profile.maintainersRepo, + ".agents/skills/review-pr/SKILL.md", + ); + const prepareSkillPath = path.join( + input.profile.maintainersRepo, + ".agents/skills/prepare-pr/SKILL.md", + ); + const mergeSkillPath = path.join( + input.profile.maintainersRepo, + ".agents/skills/merge-pr/SKILL.md", + ); + + const workflowFile = await readMarkdownFileAbsolute(workflowPath); + const reviewSkillFile = await readMarkdownFileAbsolute(reviewSkillPath); + const prepareSkillFile = await readMarkdownFileAbsolute(prepareSkillPath); + const mergeSkillFile = await readMarkdownFileAbsolute(mergeSkillPath); + + if (!workflowFile.exists) { + input.issues.push( + toIssue( + "warning", + input.profile.relativePath, + `Maintainer workflow file not found at ${workflowPath}. Falling back to a minimal local profile.`, + ), + ); + } + for (const missing of [ + { label: "review-pr skill", file: reviewSkillFile }, + { label: "prepare-pr skill", file: prepareSkillFile }, + { label: "merge-pr skill", file: mergeSkillFile }, + ]) { + if (!missing.file.exists) { + input.issues.push( + toIssue( + "warning", + input.profile.relativePath, + `${missing.label} not found at ${missing.file.absolutePath}.`, + ), + ); + } + } + + const workflowDoc = readTitleAndDescription({ + raw: workflowFile.raw, + fallbackTitle: "OpenClaw Maintainer PR Workflow", + fallbackDescription: "Private maintainer workflow loaded from a local OK Code profile.", + }); + const reviewSkillDoc = readTitleAndDescription({ + raw: reviewSkillFile.raw, + fallbackTitle: "review-pr", + fallbackDescription: "Run the read-only maintainer review workflow.", + }); + const prepareSkillDoc = readTitleAndDescription({ + raw: prepareSkillFile.raw, + fallbackTitle: "prepare-pr", + fallbackDescription: "Resolve findings, re-run gates, and push safely.", + }); + const mergeSkillDoc = readTitleAndDescription({ + raw: mergeSkillFile.raw, + fallbackTitle: "merge-pr", + fallbackDescription: "Verify readiness and perform the deterministic squash merge.", + }); + + const workflowId = `${input.profile.id}-workflow`; + const skillSets: PrSkillSetDefinition[] = [ + { + id: "review-pr", + title: reviewSkillDoc.title, + description: reviewSkillDoc.description, + skills: ["review-pr"], + allowedTools: ["local-command"], + runPolicy: "script-first", + body: reviewSkillDoc.body, + relativePath: reviewSkillPath, + }, + { + id: "prepare-pr", + title: prepareSkillDoc.title, + description: prepareSkillDoc.description, + skills: ["prepare-pr"], + allowedTools: ["local-command"], + runPolicy: "script-first", + body: prepareSkillDoc.body, + relativePath: prepareSkillPath, + }, + { + id: "merge-pr", + title: mergeSkillDoc.title, + description: mergeSkillDoc.description, + skills: ["merge-pr"], + allowedTools: ["local-command"], + runPolicy: "script-first", + body: mergeSkillDoc.body, + relativePath: mergeSkillPath, + }, + ]; + + const workflow: PrWorkflowDefinition = { + id: workflowId, + title: workflowDoc.title, + description: workflowDoc.description, + appliesTo: ["pull-request"], + blocking: true, + body: + input.profile.body.length > 0 + ? input.profile.body + : workflowDoc.body || "Local private maintainer workflow.", + relativePath: workflowPath, + steps: [ + { + id: "review-pr", + title: reviewSkillDoc.title, + kind: "skillSet", + blocking: true, + action: encodePrReviewLocalCommandAction({ + kind: "localCommand", + cwd: input.profile.maintainersRepo, + args: ["scripts/pr-review", "{{prNumber}}"], + label: "review-pr", + }), + skillSet: "review-pr", + requiresConfirmation: true, + successMessage: "Review artifacts refreshed.", + failureMessage: "The review workflow did not complete successfully.", + description: reviewSkillDoc.description, + }, + { + id: "prepare-pr", + title: prepareSkillDoc.title, + kind: "skillSet", + blocking: true, + action: encodePrReviewLocalCommandAction({ + kind: "localCommand", + cwd: input.profile.maintainersRepo, + args: ["scripts/pr-prepare", "run", "{{prNumber}}"], + label: "prepare-pr", + }), + skillSet: "prepare-pr", + requiresConfirmation: true, + successMessage: "Preparation completed and push safety checks passed.", + failureMessage: "Preparation failed or left the PR not ready to merge.", + description: prepareSkillDoc.description, + }, + { + id: "merge-pr", + title: mergeSkillDoc.title, + kind: "skillSet", + blocking: true, + action: encodePrReviewLocalCommandAction({ + kind: "localCommand", + cwd: input.profile.maintainersRepo, + args: ["scripts/pr-merge", "run", "{{prNumber}}"], + label: "merge-pr", + }), + skillSet: "merge-pr", + requiresConfirmation: true, + successMessage: "Merge workflow completed.", + failureMessage: "Merge verification failed or GitHub rejected the merge.", + description: mergeSkillDoc.description, + }, + ], + }; + + return { + source: "localProfile", + rules: { + version: "1", + title: input.profile.title, + mergePolicy: "maintainer-script-first", + conflictPolicy: "workflow-verification-before-merge", + requiredChecks: [], + requiredApprovals: 0, + blockingRules: [ + { + id: "phase-order", + title: "Run review, prepare, and merge in order", + description: "The local maintainer flow expects review-pr, prepare-pr, then merge-pr.", + }, + { + id: "artifact-handoff", + title: "Artifacts are mandatory", + description: + "review-pr and prepare-pr must generate the structured handoff artifacts before merge.", + }, + ], + advisoryRules: [ + { + id: "human-judgment", + title: "Maintainers provide judgment", + description: + "Use the local workflow to gather truth, but pause at each phase boundary for judgment.", + }, + ], + mentionGroups: [], + body: + workflowDoc.body.length > 0 + ? workflowDoc.body + : "Private maintainer workflow loaded from a local OK Code profile.", + relativePath: workflowPath, + defaultWorkflow: workflowId, + }, + workflows: [workflow], + skillSets, + defaultWorkflowId: workflowId, + issues: input.issues, + } satisfies PrReviewConfig; +} + +async function loadLocalProfileConfig(cwd: string): Promise<{ + config: PrReviewConfig | null; + issues: PrReviewConfigIssue[]; +}> { + const repositoryNameWithOwner = await determineGitHubRepositoryNameWithOwner(cwd); + if (!repositoryNameWithOwner) { + return { config: null, issues: [] }; + } + + const normalizedRepository = normalizeRepositoryMatcher(repositoryNameWithOwner); + const profileDir = path.join(resolveOkcodeHome(), LOCAL_PROFILE_RELATIVE_DIR); + const profilePaths = await listMarkdownFilesAbsolute(profileDir); + if (profilePaths.length === 0) { + return { config: null, issues: [] }; + } + + const issues: PrReviewConfigIssue[] = []; + for (const profilePath of profilePaths) { + const profileFile = await readMarkdownFileAbsolute(profilePath); + if (!profileFile.exists || !profileFile.raw) { + continue; + } + const parsed = parseLocalProfileDocument({ + absolutePath: profilePath, + raw: profileFile.raw, + }); + issues.push(...parsed.issues); + if (!parsed.profile) { + continue; + } + if (!parsed.profile.repositories.includes(normalizedRepository)) { + continue; + } + if (parsed.profile.adapter === "openclawMaintainer") { + return { + config: await buildOpenClawMaintainerConfig({ + profile: parsed.profile, + issues: [...issues], + }), + issues, + }; + } + } + + return { config: null, issues }; +} + type CacheEntry = { config: PrReviewConfig; stale: boolean; @@ -453,7 +906,21 @@ const makeRepoReviewConfig = Effect.sync(() => { string, Set<(payload: PrReviewRepoConfigUpdatedPayload) => void> >(); - const watcherByCwd = new Map(); + const watcherByCwd = new Map(); + + const emitChange = (cwd: string, relativePaths: string[]) => { + const cached = cache.get(cwd); + if (cached) { + cached.stale = true; + } + const payload: PrReviewRepoConfigUpdatedPayload = { + cwd, + relativePaths, + }; + for (const listener of listenersByCwd.get(cwd) ?? []) { + listener(payload); + } + }; const loadConfig = async (cwd: string): Promise => { const issues: PrReviewConfigIssue[] = []; @@ -511,21 +978,35 @@ const makeRepoReviewConfig = Effect.sync(() => { issues.push(...parsed.issues); } - const defaultWorkflowId = workflows.some( - (workflow) => workflow.id === parsedRules.rules.defaultWorkflow, - ) - ? parsedRules.rules.defaultWorkflow - : (workflows[0]?.id ?? DEFAULT_WORKFLOW.id); + if (rulesSource.exists || workflowPaths.length > 0 || skillSetPaths.length > 0) { + const defaultWorkflowId = workflows.some( + (workflow) => workflow.id === parsedRules.rules.defaultWorkflow, + ) + ? parsedRules.rules.defaultWorkflow + : (workflows[0]?.id ?? DEFAULT_WORKFLOW.id); + + return { + source: "repo", + rules: parsedRules.rules, + workflows, + skillSets, + defaultWorkflowId, + issues, + }; + } + + const localProfileResult = await loadLocalProfileConfig(cwd); + if (localProfileResult.config) { + return localProfileResult.config; + } + issues.push(...localProfileResult.issues); return { - source: - rulesSource.exists || workflowPaths.length > 0 || skillSetPaths.length > 0 - ? "repo" - : "default", + source: "default", rules: parsedRules.rules, workflows, skillSets, - defaultWorkflowId, + defaultWorkflowId: workflows[0]?.id ?? DEFAULT_WORKFLOW.id, issues, }; }; @@ -560,24 +1041,35 @@ const makeRepoReviewConfig = Effect.sync(() => { listeners.add(onChange); if (!watcherByCwd.has(cwd)) { - const watcher = fs.watch(cwd, { recursive: true }, (_eventType, filename) => { - const normalized = String(filename ?? "").replaceAll("\\", "/"); - if (normalized.length === 0 || !normalized.startsWith(".okcode")) { - return; - } - const cached = cache.get(cwd); - if (cached) { - cached.stale = true; - } - const payload: PrReviewRepoConfigUpdatedPayload = { - cwd, - relativePaths: [normalized], - }; - for (const listener of listenersByCwd.get(cwd) ?? []) { - listener(payload); - } - }); - watcherByCwd.set(cwd, watcher); + const watchers: fs.FSWatcher[] = []; + watchers.push( + fs.watch(cwd, { recursive: true }, (_eventType, filename) => { + const normalized = String(filename ?? "").replaceAll("\\", "/"); + if (normalized.length === 0 || !normalized.startsWith(".okcode")) { + return; + } + emitChange(cwd, [normalized]); + }), + ); + + const okcodeHome = resolveOkcodeHome(); + if (fs.existsSync(okcodeHome)) { + watchers.push( + fs.watch(okcodeHome, { recursive: true }, (_eventType, filename) => { + const normalized = String(filename ?? "").replaceAll("\\", "/"); + if ( + normalized.length === 0 || + (!normalized.startsWith(`${LOCAL_PROFILE_RELATIVE_DIR}/`) && + normalized !== LOCAL_PROFILE_RELATIVE_DIR) + ) { + return; + } + emitChange(cwd, [normalized]); + }), + ); + } + + watcherByCwd.set(cwd, watchers); } }, catch: (cause) => diff --git a/apps/server/src/prReview/localProfiles.test.ts b/apps/server/src/prReview/localProfiles.test.ts new file mode 100644 index 000000000..265505e26 --- /dev/null +++ b/apps/server/src/prReview/localProfiles.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { + decodePrReviewLocalCommandAction, + encodePrReviewLocalCommandAction, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, +} from "./localProfiles"; + +describe("prReview local profiles", () => { + it("parses GitHub HTTPS and SSH remotes", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/openclaw/openclaw.git"), + ).toBe("openclaw/openclaw"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:OpenClaw/maintainers.git"), + ).toBe("OpenClaw/maintainers"); + }); + + it("round-trips encoded local command actions", () => { + const encoded = encodePrReviewLocalCommandAction({ + kind: "localCommand", + cwd: "/Users/val/Documents/GitHub/OpenClaw/maintainers", + args: ["scripts/pr-review", "{{prNumber}}"], + label: "review-pr", + }); + + expect(decodePrReviewLocalCommandAction(encoded)).toEqual({ + kind: "localCommand", + cwd: "/Users/val/Documents/GitHub/OpenClaw/maintainers", + args: ["scripts/pr-review", "{{prNumber}}"], + label: "review-pr", + }); + }); + + it("rejects malformed encoded local command actions", () => { + expect(decodePrReviewLocalCommandAction("okcode:local-command:not-base64")).toBeNull(); + expect(decodePrReviewLocalCommandAction("scripts/pr-review 123")).toBeNull(); + }); +}); diff --git a/apps/server/src/prReview/localProfiles.ts b/apps/server/src/prReview/localProfiles.ts new file mode 100644 index 000000000..0864cbe7b --- /dev/null +++ b/apps/server/src/prReview/localProfiles.ts @@ -0,0 +1,64 @@ +export interface PrReviewLocalCommandAction { + readonly kind: "localCommand"; + readonly cwd: string; + readonly args: readonly string[]; + readonly label: string; +} + +const LOCAL_COMMAND_PREFIX = "okcode:local-command:"; + +export function encodePrReviewLocalCommandAction(input: PrReviewLocalCommandAction): string { + return `${LOCAL_COMMAND_PREFIX}${Buffer.from(JSON.stringify(input), "utf8").toString("base64url")}`; +} + +export function decodePrReviewLocalCommandAction( + value: string | null | undefined, +): PrReviewLocalCommandAction | null { + const trimmed = value?.trim() ?? ""; + if (!trimmed.startsWith(LOCAL_COMMAND_PREFIX)) { + return null; + } + + try { + const raw = trimmed.slice(LOCAL_COMMAND_PREFIX.length); + const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as { + kind?: unknown; + cwd?: unknown; + args?: unknown; + label?: unknown; + }; + if ( + parsed.kind !== "localCommand" || + typeof parsed.cwd !== "string" || + parsed.cwd.trim().length === 0 || + !Array.isArray(parsed.args) || + parsed.args.some((entry) => typeof entry !== "string") || + typeof parsed.label !== "string" || + parsed.label.trim().length === 0 + ) { + return null; + } + return { + kind: "localCommand", + cwd: parsed.cwd.trim(), + args: parsed.args.map((entry) => entry.trim()).filter((entry) => entry.length > 0), + label: parsed.label.trim(), + }; + } catch { + return null; + } +} + +export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const match = + /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; +} diff --git a/apps/web/src/components/pr-review/PrReviewShell.tsx b/apps/web/src/components/pr-review/PrReviewShell.tsx index a144453de..9bea1b0c2 100644 --- a/apps/web/src/components/pr-review/PrReviewShell.tsx +++ b/apps/web/src/components/pr-review/PrReviewShell.tsx @@ -52,6 +52,13 @@ import { const BOOL_SCHEMA = Schema.Boolean; +function resolvePrReviewConfigPath(projectCwd: string, configPath: string): string { + if (/^(?:[A-Za-z]:[\\/]|\/)/.test(configPath)) { + return configPath; + } + return joinPath(projectCwd, configPath); +} + export function PrReviewShell({ project, projects, @@ -359,10 +366,12 @@ export function PrReviewShell({ onOpenConflictDrawer: () => setConflictDrawerOpen(true), onOpenRules: () => { if (!configQuery.data) return; - void openPathInEditor(joinPath(project.cwd, configQuery.data.rules.relativePath)); + void openPathInEditor( + resolvePrReviewConfigPath(project.cwd, configQuery.data.rules.relativePath), + ); }, onOpenWorkflow: (relativePath: string) => { - void openPathInEditor(joinPath(project.cwd, relativePath)); + void openPathInEditor(resolvePrReviewConfigPath(project.cwd, relativePath)); }, onReplyToThread: async (threadId: string, body: string) => { await replyToThreadMutation.mutateAsync({ threadId, body }); diff --git a/apps/web/src/components/pr-review/PrWorkflowPanel.tsx b/apps/web/src/components/pr-review/PrWorkflowPanel.tsx index df7e88e41..2fc82ec5c 100644 --- a/apps/web/src/components/pr-review/PrWorkflowPanel.tsx +++ b/apps/web/src/components/pr-review/PrWorkflowPanel.tsx @@ -51,7 +51,11 @@ export function PrWorkflowPanel({ Repo workflow

- {config?.source === "default" ? "Using default repo workflow" : "Loaded from .okcode"} + {config?.source === "default" + ? "Using default repo workflow" + : config?.source === "localProfile" + ? "Loaded from local maintainer profile" + : "Loaded from .okcode"}