Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 22 additions & 4 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-");
Expand Down
48 changes: 33 additions & 15 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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}`
Expand All @@ -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);
Expand Down Expand Up @@ -662,27 +670,31 @@ export const makeGitManager = Effect.gen(function* () {
cwd: string,
branch: string,
upstreamRef: string | null,
headContext: Pick<BranchHeadContext, "isCrossRepository">,
headContext: Pick<BranchHeadContext, "headBranch" | "isCrossRepository">,
) =>
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) =>
Expand Down Expand Up @@ -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({
Expand Down
83 changes: 83 additions & 0 deletions apps/server/src/prReview/Layers/PrReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -592,6 +594,75 @@ const makePrReview = Effect.gen(function* () {
}) => Effect.Effect<A, PrReviewServiceError>,
): Effect.Effect<A, PrReviewServiceError> => getRepoOwnerAndName(cwd).pipe(Effect.flatMap(f));

const executeLocalCommandAction = (input: {
action: string;
prNumber: number;
step: { id: string; title: string; requiresConfirmation: boolean };
}): Effect.Effect<PrWorkflowStepRunResult, PrReviewError> =>
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 }),
Expand Down Expand Up @@ -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.`;
}
Expand Down
Loading
Loading