Merge branch 'puter/issue-5-gitea-lifecycle' into puter/issue-14-integration
# Conflicts: # bun.lock # packages/agents/package.json # packages/agents/src/agents/project-manager.ts # packages/backend/convex/agentWorkspace.ts
This commit is contained in:
132
packages/agents/src/actions/finalize-gitea-lifecycle.ts
Normal file
132
packages/agents/src/actions/finalize-gitea-lifecycle.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAction } from "@flue/runtime";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import * as v from "valibot";
|
||||
|
||||
import {
|
||||
createGiteaHttpTransport,
|
||||
runPostRunGiteaLifecycle,
|
||||
} from "../git/gitea";
|
||||
|
||||
const pullRequestOutput = v.object({
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
number: v.number(),
|
||||
status: v.picklist(["open", "closed", "merged"]),
|
||||
url: v.string(),
|
||||
});
|
||||
|
||||
const output = v.object({
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
commitSha: v.optional(v.string()),
|
||||
pullRequest: v.optional(pullRequestOutput),
|
||||
status: v.picklist([
|
||||
"no_changes",
|
||||
"committed",
|
||||
"pushed",
|
||||
"pull_request_open",
|
||||
"failed",
|
||||
]),
|
||||
});
|
||||
|
||||
export const createFinalizeGiteaLifecycle = (
|
||||
issueAgentId: string,
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
) => {
|
||||
const env = parseAgentEnv(runtimeEnv);
|
||||
const client = new ConvexHttpClient(env.CONVEX_URL);
|
||||
const issueId = issueAgentId as Id<"projectIssues">;
|
||||
|
||||
return defineAction({
|
||||
description:
|
||||
"After verification, inspect the issue workspace, commit and push its work branch, and create an open Gitea pull request. Never merge.",
|
||||
input: v.object({
|
||||
commitMessage: v.optional(v.pipe(v.string(), v.maxLength(120))),
|
||||
verified: v.boolean(),
|
||||
}),
|
||||
name: "finalize_gitea_lifecycle",
|
||||
output,
|
||||
async run({ harness, input, log }) {
|
||||
const context = await client.query(api.agentWorkspace.get, {
|
||||
issueId,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
if (!context.source) {
|
||||
throw new Error("Project has no Git source configured");
|
||||
}
|
||||
if (!context.source.defaultBranch) {
|
||||
throw new Error("Project Git source has no default branch");
|
||||
}
|
||||
if (!env.GITEA_TOKEN) {
|
||||
throw new Error(
|
||||
"GITEA_TOKEN is required for Gitea pull request creation"
|
||||
);
|
||||
}
|
||||
|
||||
const runner = {
|
||||
async run(
|
||||
command: string,
|
||||
options?: { cwd?: string; env?: Record<string, string> }
|
||||
) {
|
||||
return await harness.shell(command, options);
|
||||
},
|
||||
};
|
||||
const transport = createGiteaHttpTransport({
|
||||
baseUrl: env.GITEA_URL,
|
||||
token: env.GITEA_TOKEN,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runPostRunGiteaLifecycle({
|
||||
baseBranch: context.source.defaultBranch,
|
||||
body: `Verified changes for project issue #${context.issue.number}. Merge remains a manual review action.`,
|
||||
commitMessage: input.commitMessage,
|
||||
issueNumber: context.issue.number,
|
||||
issueTitle: context.issue.title,
|
||||
repositoryPath: context.source.repositoryPath,
|
||||
runner,
|
||||
title: `Issue #${context.issue.number}: ${context.issue.title}`,
|
||||
transport,
|
||||
verification: input.verified ? "passed" : "failed",
|
||||
workspace: "/workspace/repository",
|
||||
});
|
||||
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
|
||||
baseBranch: result.baseBranch,
|
||||
branch: result.branch,
|
||||
...(result.commitSha === undefined
|
||||
? {}
|
||||
: { commitSha: result.commitSha }),
|
||||
issueId,
|
||||
...(result.pullRequest === undefined
|
||||
? {}
|
||||
: { pullRequest: result.pullRequest }),
|
||||
status: result.status,
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
log.info("Gitea lifecycle completed", {
|
||||
branch: result.branch,
|
||||
status: result.status,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const branchResult = await runner.run("git branch --show-current", {
|
||||
cwd: "/workspace/repository",
|
||||
});
|
||||
const branch = branchResult.stdout.trim() || "unknown";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await client.mutation(api.agentWorkspace.recordGiteaLifecycle, {
|
||||
baseBranch: context.source.defaultBranch,
|
||||
branch,
|
||||
error: message,
|
||||
issueId,
|
||||
status: "failed",
|
||||
token: env.FLUE_DB_TOKEN,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { parseAgentEnv } from "@code/env/agent";
|
||||
import { defineAgent } from "@flue/runtime";
|
||||
import type { AgentRouteHandler } from "@flue/runtime";
|
||||
|
||||
import { createFinalizeGiteaLifecycle } from "../actions/finalize-gitea-lifecycle";
|
||||
import { agentOs } from "../sandboxes/agent-os";
|
||||
import { createProjectTools } from "../tools/project";
|
||||
|
||||
@@ -14,6 +15,7 @@ export default defineAgent(({ env, id }) => {
|
||||
const { AGENT_MODEL_NAME, AGENT_MODEL_PROVIDER } = parseAgentEnv(env);
|
||||
|
||||
return {
|
||||
actions: [createFinalizeGiteaLifecycle(id, env)],
|
||||
cwd: "/workspace/repository",
|
||||
description,
|
||||
instructions: `You are the issue-scoped project manager and coding agent.
|
||||
@@ -22,7 +24,7 @@ Start every run by calling lookup_issue_context, reading /workspace/control/issu
|
||||
|
||||
Work only on the bound issue. The repository checkout is the current working directory; inspect existing source files before changing them. If a missing product decision makes safe progress impossible, publish the current work.md and steps.md, report needs-input, then ask one focused question. Otherwise implement the complete issue, run the relevant install, edit, test, or scenario command in AgentOS, and preserve command evidence.
|
||||
|
||||
Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. Report completed only after verification. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
|
||||
Before finishing, update the operational files under /workspace/control/artifacts and publish each changed canonical artifact with publish_project_artifact. After verification, call finalize_gitea_lifecycle with verified=true; it owns the post-run Git/Gitea lifecycle and never merges. Report completed only after that action succeeds or reports no_changes. On an unrecoverable error, report failed with the exact blocker. Never claim a repository change or command result you did not observe.`,
|
||||
model: `${AGENT_MODEL_PROVIDER}/${AGENT_MODEL_NAME}`,
|
||||
sandbox: agentOs(env),
|
||||
tools: createProjectTools(id, env),
|
||||
|
||||
155
packages/agents/src/git/gitea.test.ts
Normal file
155
packages/agents/src/git/gitea.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createGiteaHttpTransport, runPostRunGiteaLifecycle } from "./gitea";
|
||||
import type { GitCommandRunner, GiteaTransport } from "./gitea";
|
||||
|
||||
const repository = {
|
||||
cloneUrl: "https://git.openputer.com/puter/zopu-code.git",
|
||||
defaultBranch: "main",
|
||||
htmlUrl: "https://git.openputer.com/puter/zopu-code",
|
||||
name: "zopu-code",
|
||||
sshUrl: "ssh://git@git.openputer.com:2222/puter/zopu-code.git",
|
||||
};
|
||||
|
||||
const makeRunner = (): GitCommandRunner & { readonly commands: string[] } => {
|
||||
const commands: string[] = [];
|
||||
let statusCalls = 0;
|
||||
return {
|
||||
commands,
|
||||
run(command) {
|
||||
commands.push(command);
|
||||
if (command === "git branch --show-current") {
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: "work/issue-5\n",
|
||||
});
|
||||
}
|
||||
if (command === "git status --porcelain=v1") {
|
||||
statusCalls += 1;
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: statusCalls === 1 ? " M src/gitea.ts\n" : "",
|
||||
});
|
||||
}
|
||||
if (command === "git diff --no-ext-diff --binary") {
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: "diff --git ...",
|
||||
});
|
||||
}
|
||||
if (command === "git rev-parse HEAD") {
|
||||
return Promise.resolve({
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
stdout: "abc123\n",
|
||||
});
|
||||
}
|
||||
if (command.includes("git status --porcelain=v1")) {
|
||||
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
|
||||
}
|
||||
return Promise.resolve({ exitCode: 0, stderr: "", stdout: "" });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("Gitea lifecycle adapter", () => {
|
||||
test("inspects, commits, pushes, and creates an open PR through mocked boundaries", async () => {
|
||||
const runner = makeRunner();
|
||||
const pullRequests: unknown[] = [];
|
||||
const transport: GiteaTransport = {
|
||||
createPullRequest(input) {
|
||||
pullRequests.push(input);
|
||||
return Promise.resolve({
|
||||
base: { ref: input.base },
|
||||
head: { ref: input.head },
|
||||
htmlUrl: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
number: 5,
|
||||
state: "open",
|
||||
});
|
||||
},
|
||||
getRepository() {
|
||||
return Promise.resolve(repository);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runPostRunGiteaLifecycle({
|
||||
baseBranch: "main",
|
||||
body: "Generated by the verified project run.",
|
||||
issueNumber: 5,
|
||||
issueTitle: "Publish verified changes",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
runner,
|
||||
transport,
|
||||
verification: "passed",
|
||||
workspace: "/workspace",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
branch: "work/issue-5",
|
||||
commitSha: "abc123",
|
||||
pullRequest: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-5",
|
||||
number: 5,
|
||||
status: "open",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
});
|
||||
expect(runner.commands).toEqual([
|
||||
"git branch --show-current",
|
||||
"git status --porcelain=v1",
|
||||
"git diff --no-ext-diff --binary",
|
||||
"git add --all && git commit -m 'feat(issue-5): Publish verified changes'",
|
||||
"git rev-parse HEAD",
|
||||
"git status --porcelain=v1",
|
||||
"git push 'ssh://git@git.openputer.com:2222/puter/zopu-code.git' HEAD:'work/issue-5'",
|
||||
]);
|
||||
expect(pullRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("mocks the HTTP Gitea boundary without calling the network", async () => {
|
||||
const requests: Request[] = [];
|
||||
const transport = createGiteaHttpTransport({
|
||||
baseUrl: "https://git.openputer.com",
|
||||
fetch: (input, init) => {
|
||||
const request = new Request(String(input), init);
|
||||
requests.push(request);
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
request.method === "GET"
|
||||
? JSON.stringify(repository)
|
||||
: JSON.stringify({
|
||||
base: { ref: "main" },
|
||||
head: { ref: "work/issue-5" },
|
||||
html_url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
number: 5,
|
||||
state: "open",
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } }
|
||||
)
|
||||
);
|
||||
},
|
||||
token: "scoped-test-token",
|
||||
});
|
||||
|
||||
await transport.getRepository("puter/zopu-code");
|
||||
await transport.createPullRequest({
|
||||
base: "main",
|
||||
body: "body",
|
||||
head: "work/issue-5",
|
||||
repositoryPath: "puter/zopu-code",
|
||||
title: "title",
|
||||
});
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0]?.url).toBe(
|
||||
"https://git.openputer.com/api/v1/repos/puter/zopu-code"
|
||||
);
|
||||
expect(requests[1]?.headers.get("authorization")).toBe(
|
||||
"token scoped-test-token"
|
||||
);
|
||||
});
|
||||
});
|
||||
307
packages/agents/src/git/gitea.ts
Normal file
307
packages/agents/src/git/gitea.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
decideGitLifecycle,
|
||||
GitLifecycleError,
|
||||
inspectGitWorkspace,
|
||||
makeCommitMessage,
|
||||
validatePullRequestMetadata,
|
||||
} from "@code/primitives/git";
|
||||
import type {
|
||||
GitLifecycleDecisionResult,
|
||||
GitLifecycleResult,
|
||||
GitWorkspaceInspection,
|
||||
} from "@code/primitives/git";
|
||||
import { Effect } from "effect";
|
||||
|
||||
export interface GitCommandResult {
|
||||
readonly exitCode: number;
|
||||
readonly stderr: string;
|
||||
readonly stdout: string;
|
||||
}
|
||||
|
||||
export interface GitCommandRunner {
|
||||
readonly run: (
|
||||
command: string,
|
||||
options?: {
|
||||
readonly cwd?: string;
|
||||
readonly env?: Record<string, string>;
|
||||
}
|
||||
) => Promise<GitCommandResult>;
|
||||
}
|
||||
|
||||
export interface GiteaRepository {
|
||||
readonly cloneUrl: string;
|
||||
readonly defaultBranch: string;
|
||||
readonly htmlUrl: string;
|
||||
readonly name: string;
|
||||
readonly sshUrl: string;
|
||||
}
|
||||
|
||||
export interface GiteaPullRequest {
|
||||
readonly base: { readonly ref: string };
|
||||
readonly head: { readonly ref: string };
|
||||
readonly htmlUrl: string;
|
||||
readonly number: number;
|
||||
readonly state: "open" | "closed" | "merged";
|
||||
}
|
||||
|
||||
export interface GiteaTransport {
|
||||
readonly createPullRequest: (input: {
|
||||
readonly base: string;
|
||||
readonly body: string;
|
||||
readonly head: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly title: string;
|
||||
}) => Promise<GiteaPullRequest>;
|
||||
readonly getRepository: (repositoryPath: string) => Promise<GiteaRepository>;
|
||||
}
|
||||
|
||||
export interface GiteaHttpTransportOptions {
|
||||
readonly baseUrl: string;
|
||||
readonly fetch?: (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit
|
||||
) => Promise<Response>;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
|
||||
const providerFailure = (
|
||||
message: string,
|
||||
reason: "CommandFailed" | "RemoteRejected"
|
||||
) => new GitLifecycleError({ message, reason });
|
||||
|
||||
const runChecked = async (
|
||||
runner: GitCommandRunner,
|
||||
command: string,
|
||||
options?: Parameters<GitCommandRunner["run"]>[1]
|
||||
): Promise<string> => {
|
||||
let result: GitCommandResult;
|
||||
try {
|
||||
result = await runner.run(command, options);
|
||||
} catch (error) {
|
||||
throw new GitLifecycleError({
|
||||
message: `Git command could not run: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
reason: "CommandFailed",
|
||||
});
|
||||
}
|
||||
if (result.exitCode !== 0) {
|
||||
throw providerFailure(
|
||||
result.stderr.trim() || `Git command failed: ${command}`,
|
||||
command.startsWith("git push") ? "RemoteRejected" : "CommandFailed"
|
||||
);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
|
||||
const toHttpPath = (repositoryPath: string): string =>
|
||||
repositoryPath
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
|
||||
const requestJson = async <T>(
|
||||
options: GiteaHttpTransportOptions,
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> => {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (options.fetch ?? globalThis.fetch)(
|
||||
`${options.baseUrl.replace(/\/$/u, "")}${path}`,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${options.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new GitLifecycleError({
|
||||
message: `Gitea request could not run: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
reason: "RequestFailed",
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new GitLifecycleError({
|
||||
message: `Gitea request failed (${response.status}): ${detail}`,
|
||||
reason:
|
||||
response.status === 401 || response.status === 403
|
||||
? "Authentication"
|
||||
: "RequestFailed",
|
||||
});
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
export const createGiteaHttpTransport = (
|
||||
options: GiteaHttpTransportOptions
|
||||
): GiteaTransport => ({
|
||||
async createPullRequest(input) {
|
||||
const response = await requestJson<{
|
||||
base: { ref: string };
|
||||
head: { ref: string };
|
||||
html_url: string;
|
||||
number: number;
|
||||
state: "open" | "closed" | "merged";
|
||||
}>(options, `/api/v1/repos/${toHttpPath(input.repositoryPath)}/pulls`, {
|
||||
body: JSON.stringify({
|
||||
base: input.base,
|
||||
body: input.body,
|
||||
head: input.head,
|
||||
title: input.title,
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
return {
|
||||
base: response.base,
|
||||
head: response.head,
|
||||
htmlUrl: response.html_url,
|
||||
number: response.number,
|
||||
state: response.state,
|
||||
};
|
||||
},
|
||||
async getRepository(repositoryPath) {
|
||||
const response = await requestJson<{
|
||||
clone_url: string;
|
||||
default_branch: string;
|
||||
html_url: string;
|
||||
name: string;
|
||||
ssh_url: string;
|
||||
}>(options, `/api/v1/repos/${toHttpPath(repositoryPath)}`);
|
||||
return {
|
||||
cloneUrl: response.clone_url,
|
||||
defaultBranch: response.default_branch,
|
||||
htmlUrl: response.html_url,
|
||||
name: response.name,
|
||||
sshUrl: response.ssh_url,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export interface RunPostRunGiteaLifecycleInput {
|
||||
readonly baseBranch: string;
|
||||
readonly body: string;
|
||||
readonly commitMessage?: string;
|
||||
readonly issueNumber: number;
|
||||
readonly issueTitle: string;
|
||||
readonly repositoryPath: string;
|
||||
readonly runner: GitCommandRunner;
|
||||
readonly title?: string;
|
||||
readonly transport: GiteaTransport;
|
||||
readonly verification: "passed" | "failed" | "not-run";
|
||||
readonly workspace: string;
|
||||
}
|
||||
|
||||
const inspect = async (
|
||||
input: RunPostRunGiteaLifecycleInput
|
||||
): Promise<GitWorkspaceInspection> => {
|
||||
const [branch, status, diff] = await Promise.all([
|
||||
runChecked(input.runner, "git branch --show-current", {
|
||||
cwd: input.workspace,
|
||||
}),
|
||||
runChecked(input.runner, "git status --porcelain=v1", {
|
||||
cwd: input.workspace,
|
||||
}),
|
||||
runChecked(input.runner, "git diff --no-ext-diff --binary", {
|
||||
cwd: input.workspace,
|
||||
}),
|
||||
]);
|
||||
return await Effect.runPromise(
|
||||
inspectGitWorkspace({
|
||||
branch: branch.trim(),
|
||||
diff,
|
||||
status,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const runPostRunGiteaLifecycle = async (
|
||||
input: RunPostRunGiteaLifecycleInput
|
||||
): Promise<GitLifecycleResult> => {
|
||||
const inspection = await inspect(input);
|
||||
const initialDecision = (await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: input.baseBranch,
|
||||
inspection,
|
||||
pushed: false,
|
||||
verification: input.verification,
|
||||
})
|
||||
)) as GitLifecycleDecisionResult;
|
||||
|
||||
if (initialDecision.decision === "finish") {
|
||||
return {
|
||||
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
|
||||
branch: inspection.branch,
|
||||
commitSha: undefined,
|
||||
pullRequest: undefined,
|
||||
status: "no_changes",
|
||||
};
|
||||
}
|
||||
|
||||
const repository = await input.transport.getRepository(input.repositoryPath);
|
||||
|
||||
await runChecked(
|
||||
input.runner,
|
||||
`git add --all && git commit -m ${shellQuote(
|
||||
input.commitMessage ??
|
||||
makeCommitMessage(input.issueNumber, input.issueTitle)
|
||||
)}`,
|
||||
{ cwd: input.workspace }
|
||||
);
|
||||
const commitOutput = await runChecked(input.runner, "git rev-parse HEAD", {
|
||||
cwd: input.workspace,
|
||||
});
|
||||
const commitSha = commitOutput.trim();
|
||||
const cleanStatus = await runChecked(
|
||||
input.runner,
|
||||
"git status --porcelain=v1",
|
||||
{ cwd: input.workspace }
|
||||
);
|
||||
if (cleanStatus.trim().length > 0) {
|
||||
throw providerFailure(
|
||||
"Git workspace remained dirty after commit",
|
||||
"CommandFailed"
|
||||
);
|
||||
}
|
||||
|
||||
await runChecked(
|
||||
input.runner,
|
||||
`git push ${shellQuote(repository.sshUrl)} HEAD:${shellQuote(inspection.branch)}`,
|
||||
{ cwd: input.workspace }
|
||||
);
|
||||
|
||||
const pullRequest = await input.transport.createPullRequest({
|
||||
base: input.baseBranch,
|
||||
body: input.body,
|
||||
head: inspection.branch,
|
||||
repositoryPath: input.repositoryPath,
|
||||
title: input.title ?? `Issue #${input.issueNumber}: ${input.issueTitle}`,
|
||||
});
|
||||
const metadata = await Effect.runPromise(
|
||||
validatePullRequestMetadata({
|
||||
baseBranch: pullRequest.base.ref,
|
||||
branch: pullRequest.head.ref,
|
||||
number: pullRequest.number,
|
||||
status: pullRequest.state,
|
||||
url: pullRequest.htmlUrl,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
baseBranch: input.baseBranch as GitLifecycleResult["baseBranch"],
|
||||
branch: inspection.branch,
|
||||
commitSha,
|
||||
pullRequest: metadata,
|
||||
status: "pull_request_open",
|
||||
};
|
||||
};
|
||||
@@ -278,3 +278,111 @@ export const setStatus = mutation({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const lifecycleStatus = v.union(
|
||||
v.literal("no_changes"),
|
||||
v.literal("committed"),
|
||||
v.literal("pushed"),
|
||||
v.literal("pull_request_open"),
|
||||
v.literal("failed")
|
||||
);
|
||||
|
||||
const pullRequestMetadata = v.object({
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
number: v.number(),
|
||||
status: v.union(v.literal("open"), v.literal("closed"), v.literal("merged")),
|
||||
url: v.string(),
|
||||
});
|
||||
|
||||
export const recordGiteaLifecycle = mutation({
|
||||
args: {
|
||||
baseBranch: v.string(),
|
||||
branch: v.string(),
|
||||
commitSha: v.optional(v.string()),
|
||||
error: v.optional(v.string()),
|
||||
issueId: v.id("projectIssues"),
|
||||
pullRequest: v.optional(pullRequestMetadata),
|
||||
status: lifecycleStatus,
|
||||
token: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireAgent(args.token);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
}
|
||||
const run = await ctx.db
|
||||
.query("projectWorkRuns")
|
||||
.withIndex("by_issue", (q) => q.eq("issueId", issue._id))
|
||||
.unique();
|
||||
if (!run) {
|
||||
throw new ConvexError("Agent work run not found");
|
||||
}
|
||||
|
||||
const events = await ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_issue_and_createdAt", (q) => q.eq("issueId", issue._id))
|
||||
.order("desc")
|
||||
.take(100);
|
||||
const existing = events.find((event) => {
|
||||
if (!event.kind.startsWith("gitea.")) {
|
||||
return false;
|
||||
}
|
||||
const data = event.data as {
|
||||
branch?: unknown;
|
||||
commitSha?: unknown;
|
||||
status?: unknown;
|
||||
};
|
||||
return (
|
||||
data.branch === args.branch &&
|
||||
data.commitSha === args.commitSha &&
|
||||
data.status === args.status
|
||||
);
|
||||
});
|
||||
if (existing) {
|
||||
return existing.data;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const data = {
|
||||
baseBranch: args.baseBranch,
|
||||
branch: args.branch,
|
||||
...(args.commitSha === undefined ? {} : { commitSha: args.commitSha }),
|
||||
...(args.error === undefined ? {} : { error: args.error.slice(0, 2000) }),
|
||||
...(args.pullRequest === undefined
|
||||
? {}
|
||||
: { pullRequest: args.pullRequest }),
|
||||
status: args.status,
|
||||
};
|
||||
const artifact = await ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", issue.projectId).eq("path", "artifacts.md")
|
||||
)
|
||||
.unique();
|
||||
if (artifact) {
|
||||
const pullRequestLine = args.pullRequest
|
||||
? `- Pull request: [#${args.pullRequest.number}](${args.pullRequest.url}) (${args.pullRequest.status})\n`
|
||||
: "";
|
||||
const commitLine = args.commitSha ? `- Commit: ${args.commitSha}\n` : "";
|
||||
await ctx.db.patch("projectArtifacts", artifact._id, {
|
||||
content: `${artifact.content}\n## Git lifecycle\n\n- Status: ${args.status}\n- Branch: ${args.branch}\n- Base branch: ${args.baseBranch}\n${commitLine}${pullRequestLine}${args.error ? `- Error: ${args.error.slice(0, 1000)}\n` : ""}`,
|
||||
revision: artifact.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
}
|
||||
await ctx.db.insert("projectEvents", {
|
||||
createdAt: timestamp,
|
||||
data,
|
||||
issueId: issue._id,
|
||||
kind:
|
||||
args.pullRequest === undefined
|
||||
? "gitea.lifecycle.updated"
|
||||
: "gitea.pull_request.created",
|
||||
projectId: issue.projectId,
|
||||
runId: run._id,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -223,4 +223,63 @@ describe("projectEvents", () => {
|
||||
const events = await eventsForProject(t, projectId);
|
||||
expect(events.some((e) => e.kind === "agent.completed")).toBe(false);
|
||||
});
|
||||
|
||||
test("Gitea lifecycle persists PR metadata in an event and artifact", async () => {
|
||||
const t = convexTest({ schema, modules });
|
||||
const projectId = await createTestProject(t);
|
||||
const issueId = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.projectIssues.create, {
|
||||
body: "A representative issue body long enough to pass validation",
|
||||
projectId,
|
||||
title: "Representative issue",
|
||||
});
|
||||
await t.mutation(api.agentWorkspace.ensureRun, {
|
||||
daemonId: "daemon-1",
|
||||
issueId,
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
await t.mutation(api.agentWorkspace.recordGiteaLifecycle, {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-1",
|
||||
commitSha: "abc123",
|
||||
issueId,
|
||||
pullRequest: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-1",
|
||||
number: 5,
|
||||
status: "open",
|
||||
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
token: FLUE_DB_TOKEN,
|
||||
});
|
||||
|
||||
const events = await eventsForProject(t, projectId);
|
||||
const event = events.find(
|
||||
(item) => item.kind === "gitea.pull_request.created"
|
||||
);
|
||||
expect(event?.data).toMatchObject({
|
||||
branch: "work/issue-1",
|
||||
commitSha: "abc123",
|
||||
pullRequest: {
|
||||
number: 5,
|
||||
status: "open",
|
||||
},
|
||||
status: "pull_request_open",
|
||||
});
|
||||
|
||||
const artifacts = await t.query((ctx) =>
|
||||
ctx.db
|
||||
.query("projectArtifacts")
|
||||
.withIndex("by_project_and_path", (q) =>
|
||||
q.eq("projectId", projectId).eq("path", "artifacts.md")
|
||||
)
|
||||
.unique()
|
||||
);
|
||||
expect(artifacts?.content).toContain(
|
||||
"https://git.openputer.com/puter/zopu-code/pulls/5"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,3 +191,21 @@ export const list = query({
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
|
||||
export const events = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
try {
|
||||
await requireProjectMember(ctx, args.projectId);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return await ctx.db
|
||||
.query("projectEvents")
|
||||
.withIndex("by_project_and_createdAt", (q) =>
|
||||
q.eq("projectId", args.projectId)
|
||||
)
|
||||
.order("desc")
|
||||
.take(100);
|
||||
},
|
||||
});
|
||||
|
||||
2
packages/env/src/agent.ts
vendored
2
packages/env/src/agent.ts
vendored
@@ -11,6 +11,8 @@ const agentEnvSchema = z.object({
|
||||
CONVEX_URL: z.url(),
|
||||
DAEMON_ID: z.string().min(1),
|
||||
FLUE_DB_TOKEN: z.string().min(1),
|
||||
GITEA_TOKEN: z.string().min(1).optional(),
|
||||
GITEA_URL: z.url().default("https://git.openputer.com"),
|
||||
});
|
||||
|
||||
export type AgentEnv = z.infer<typeof agentEnvSchema>;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./agent-os": "./src/agent-os.ts",
|
||||
"./git": "./src/git.ts",
|
||||
"./project": "./src/project.ts",
|
||||
"./project-issue": "./src/project-issue.ts",
|
||||
"./project-workspace": "./src/project-workspace.ts",
|
||||
|
||||
145
packages/primitives/src/git.test.ts
Normal file
145
packages/primitives/src/git.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Effect, Exit } from "effect";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
decideGitLifecycle,
|
||||
inspectGitWorkspace,
|
||||
makeCommitMessage,
|
||||
validatePullRequestMetadata,
|
||||
} from "./git";
|
||||
|
||||
const inspectionInput = {
|
||||
branch: "work/issue-5",
|
||||
diff: "diff --git a/file.ts b/file.ts",
|
||||
status: " M file.ts",
|
||||
};
|
||||
|
||||
const inspection = await Effect.runPromise(
|
||||
inspectGitWorkspace(inspectionInput)
|
||||
);
|
||||
|
||||
describe("Git lifecycle primitives", () => {
|
||||
test("parses workspace status and treats a diff as publishable changes", () => {
|
||||
expect(inspection).toMatchObject({
|
||||
branch: "work/issue-5",
|
||||
hasChanges: true,
|
||||
status: [{ index: " ", path: "file.ts", worktree: "M" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("requires verification before any publish action", async () => {
|
||||
const result = await Effect.runPromiseExit(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
inspection,
|
||||
pushed: false,
|
||||
verification: "not-run",
|
||||
})
|
||||
);
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true);
|
||||
if (Exit.isFailure(result)) {
|
||||
const failure = result.cause as unknown as {
|
||||
readonly reasons: readonly [
|
||||
{ readonly error: { readonly reason: string } },
|
||||
];
|
||||
};
|
||||
expect(failure.reasons[0]?.error.reason).toBe("VerificationRequired");
|
||||
}
|
||||
});
|
||||
|
||||
test("never publishes the default branch", async () => {
|
||||
const result = await Effect.runPromiseExit(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "work/issue-5",
|
||||
inspection,
|
||||
pushed: false,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true);
|
||||
if (Exit.isFailure(result)) {
|
||||
const failure = result.cause as unknown as {
|
||||
readonly reasons: readonly [
|
||||
{ readonly error: { readonly reason: string } },
|
||||
];
|
||||
};
|
||||
expect(failure.reasons[0]?.error.reason).toBe("InvalidBranch");
|
||||
}
|
||||
});
|
||||
|
||||
test("moves verified changes through commit, push, PR, then manual merge", async () => {
|
||||
const commit = await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
inspection,
|
||||
pushed: false,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
expect(commit).toEqual({ decision: "commit", status: "committed" });
|
||||
|
||||
const push = await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
commitSha: "abc123",
|
||||
inspection: { ...inspection, hasChanges: false, status: [] },
|
||||
pushed: false,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
expect(push).toEqual({ decision: "push", status: "pushed" });
|
||||
|
||||
const pr = await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
commitSha: "abc123",
|
||||
inspection: { ...inspection, hasChanges: false, status: [] },
|
||||
pushed: true,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
expect(pr).toEqual({
|
||||
decision: "create_pull_request",
|
||||
status: "pull_request_open",
|
||||
});
|
||||
|
||||
const merge = await Effect.runPromise(
|
||||
decideGitLifecycle({
|
||||
baseBranch: "main",
|
||||
commitSha: "abc123",
|
||||
inspection: { ...inspection, hasChanges: false, status: [] },
|
||||
pullRequest: {
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-5",
|
||||
number: 5,
|
||||
status: "open",
|
||||
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
},
|
||||
pushed: true,
|
||||
verification: "passed",
|
||||
})
|
||||
);
|
||||
expect(merge).toEqual({
|
||||
decision: "manual_merge",
|
||||
status: "pull_request_open",
|
||||
});
|
||||
});
|
||||
|
||||
test("validates PR metadata and keeps merge manual", async () => {
|
||||
const metadata = await Effect.runPromise(
|
||||
validatePullRequestMetadata({
|
||||
baseBranch: "main",
|
||||
branch: "work/issue-5",
|
||||
number: 5,
|
||||
status: "open",
|
||||
url: "https://git.openputer.com/puter/zopu-code/pulls/5",
|
||||
})
|
||||
);
|
||||
expect(metadata.status).toBe("open");
|
||||
expect(makeCommitMessage(5, "Ship Git lifecycle")).toBe(
|
||||
"feat(issue-5): Ship Git lifecycle"
|
||||
);
|
||||
});
|
||||
});
|
||||
250
packages/primitives/src/git.ts
Normal file
250
packages/primitives/src/git.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
const MeaningfulString = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
export const GitBranchName = MeaningfulString.pipe(
|
||||
Schema.check(
|
||||
Schema.makeFilter(
|
||||
(value) =>
|
||||
!value.startsWith("-") &&
|
||||
!value.includes("..") &&
|
||||
!value.includes(" ") &&
|
||||
!value.includes("~") &&
|
||||
!value.includes("^") &&
|
||||
!value.includes(":") &&
|
||||
!value.includes("\\"),
|
||||
{ expected: "a valid Git branch name" }
|
||||
)
|
||||
)
|
||||
);
|
||||
export type GitBranchName = typeof GitBranchName.Type;
|
||||
|
||||
export const GitStatusEntry = Schema.Struct({
|
||||
index: Schema.String,
|
||||
path: MeaningfulString,
|
||||
raw: Schema.String,
|
||||
worktree: Schema.String,
|
||||
});
|
||||
export type GitStatusEntry = typeof GitStatusEntry.Type;
|
||||
|
||||
export const GitWorkspaceInspection = Schema.Struct({
|
||||
branch: GitBranchName,
|
||||
diff: Schema.String,
|
||||
hasChanges: Schema.Boolean,
|
||||
status: Schema.Array(GitStatusEntry),
|
||||
});
|
||||
export type GitWorkspaceInspection = typeof GitWorkspaceInspection.Type;
|
||||
|
||||
export const GitVerification = Schema.Literals(["passed", "failed", "not-run"]);
|
||||
export type GitVerification = typeof GitVerification.Type;
|
||||
|
||||
export const PullRequestStatus = Schema.Literals(["open", "closed", "merged"]);
|
||||
export type PullRequestStatus = typeof PullRequestStatus.Type;
|
||||
|
||||
export const PullRequestMetadata = Schema.Struct({
|
||||
baseBranch: GitBranchName,
|
||||
branch: GitBranchName,
|
||||
number: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||
status: PullRequestStatus,
|
||||
url: Schema.String.check(
|
||||
Schema.makeFilter(
|
||||
(value) => {
|
||||
try {
|
||||
return new URL(value).protocol.startsWith("http");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ expected: "a valid HTTP pull request URL" }
|
||||
)
|
||||
),
|
||||
});
|
||||
export type PullRequestMetadata = typeof PullRequestMetadata.Type;
|
||||
|
||||
export const GitLifecycleStatus = Schema.Literals([
|
||||
"no_changes",
|
||||
"committed",
|
||||
"pushed",
|
||||
"pull_request_open",
|
||||
"failed",
|
||||
]);
|
||||
export type GitLifecycleStatus = typeof GitLifecycleStatus.Type;
|
||||
|
||||
export const GitLifecycleResult = Schema.Struct({
|
||||
baseBranch: GitBranchName,
|
||||
branch: GitBranchName,
|
||||
commitSha: Schema.optional(MeaningfulString),
|
||||
pullRequest: Schema.optional(PullRequestMetadata),
|
||||
status: GitLifecycleStatus,
|
||||
});
|
||||
export type GitLifecycleResult = typeof GitLifecycleResult.Type;
|
||||
|
||||
export const GitLifecycleDecision = Schema.Literals([
|
||||
"finish",
|
||||
"commit",
|
||||
"push",
|
||||
"create_pull_request",
|
||||
"manual_merge",
|
||||
]);
|
||||
export type GitLifecycleDecision = typeof GitLifecycleDecision.Type;
|
||||
|
||||
export const GitLifecycleDecisionInput = Schema.Struct({
|
||||
baseBranch: GitBranchName,
|
||||
commitSha: Schema.optional(MeaningfulString),
|
||||
inspection: GitWorkspaceInspection,
|
||||
pullRequest: Schema.optional(PullRequestMetadata),
|
||||
pushed: Schema.Boolean,
|
||||
verification: GitVerification,
|
||||
});
|
||||
export type GitLifecycleDecisionInput = typeof GitLifecycleDecisionInput.Type;
|
||||
|
||||
export const GitLifecycleDecisionResult = Schema.Struct({
|
||||
decision: GitLifecycleDecision,
|
||||
status: GitLifecycleStatus,
|
||||
});
|
||||
export type GitLifecycleDecisionResult = typeof GitLifecycleDecisionResult.Type;
|
||||
|
||||
export const GitLifecycleErrorReason = Schema.Literals([
|
||||
"Authentication",
|
||||
"CommandFailed",
|
||||
"InvalidInput",
|
||||
"InvalidBranch",
|
||||
"NoChanges",
|
||||
"InvalidPullRequest",
|
||||
"RemoteRejected",
|
||||
"RequestFailed",
|
||||
"VerificationRequired",
|
||||
]);
|
||||
export type GitLifecycleErrorReason = typeof GitLifecycleErrorReason.Type;
|
||||
|
||||
export class GitLifecycleError extends Schema.TaggedErrorClass<GitLifecycleError>()(
|
||||
"GitLifecycleError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: GitLifecycleErrorReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
const invalidInput = (message: string) =>
|
||||
new GitLifecycleError({ message, reason: "InvalidInput" });
|
||||
|
||||
export const parseGitStatus = (rawStatus: string): GitStatusEntry[] =>
|
||||
rawStatus
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((raw) => ({
|
||||
index: raw.slice(0, 1),
|
||||
path: raw.slice(3).trim(),
|
||||
raw,
|
||||
worktree: raw.slice(1, 2),
|
||||
}));
|
||||
|
||||
export const inspectGitWorkspace = Effect.fn("Git.inspectWorkspace")(
|
||||
function* inspectGitWorkspace(input: unknown) {
|
||||
const decoded = yield* Schema.decodeUnknownEffect(
|
||||
Schema.Struct({
|
||||
branch: GitBranchName,
|
||||
diff: Schema.String,
|
||||
status: Schema.String,
|
||||
})
|
||||
)(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
|
||||
|
||||
const status = parseGitStatus(decoded.status);
|
||||
return {
|
||||
branch: decoded.branch,
|
||||
diff: decoded.diff,
|
||||
hasChanges: status.length > 0 || decoded.diff.length > 0,
|
||||
status,
|
||||
} satisfies GitWorkspaceInspection;
|
||||
}
|
||||
);
|
||||
|
||||
export const decideGitLifecycle = Effect.fn("Git.decideLifecycle")(
|
||||
function* decideGitLifecycle(input: unknown) {
|
||||
const decoded = yield* Schema.decodeUnknownEffect(
|
||||
GitLifecycleDecisionInput
|
||||
)(input).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
|
||||
|
||||
if (decoded.inspection.branch === decoded.baseBranch) {
|
||||
return yield* Effect.fail(
|
||||
new GitLifecycleError({
|
||||
message: `Refusing to publish the default branch ${decoded.baseBranch}`,
|
||||
reason: "InvalidBranch",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded.verification !== "passed") {
|
||||
return yield* Effect.fail(
|
||||
new GitLifecycleError({
|
||||
message: "Verified changes are required before publishing",
|
||||
reason: "VerificationRequired",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!decoded.inspection.hasChanges && decoded.commitSha === undefined) {
|
||||
return {
|
||||
decision: "finish" as const,
|
||||
status: "no_changes" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
|
||||
if (decoded.commitSha === undefined) {
|
||||
return {
|
||||
decision: "commit" as const,
|
||||
status: "committed" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
|
||||
if (!decoded.pushed) {
|
||||
return {
|
||||
decision: "push" as const,
|
||||
status: "pushed" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
|
||||
if (decoded.pullRequest === undefined) {
|
||||
return {
|
||||
decision: "create_pull_request" as const,
|
||||
status: "pull_request_open" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
decision: "manual_merge" as const,
|
||||
status: "pull_request_open" as const,
|
||||
} satisfies GitLifecycleDecisionResult;
|
||||
}
|
||||
);
|
||||
|
||||
export const validatePullRequestMetadata = Effect.fn(
|
||||
"Git.validatePullRequestMetadata"
|
||||
)(function* validatePullRequestMetadata(input: unknown) {
|
||||
const metadata = yield* Schema.decodeUnknownEffect(PullRequestMetadata)(
|
||||
input
|
||||
).pipe(Effect.mapError((cause) => invalidInput(cause.message)));
|
||||
|
||||
if (metadata.branch === metadata.baseBranch) {
|
||||
return yield* Effect.fail(
|
||||
new GitLifecycleError({
|
||||
message: "A pull request must target a different branch",
|
||||
reason: "InvalidPullRequest",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
});
|
||||
|
||||
export const makeCommitMessage = (
|
||||
issueNumber: number,
|
||||
title: string
|
||||
): string => {
|
||||
const normalizedTitle = title.replaceAll(/\s+/gu, " ").trim();
|
||||
return `feat(issue-${issueNumber}): ${normalizedTitle}`.slice(0, 120);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||
export * from "./agent-os";
|
||||
export * from "./git";
|
||||
export * from "./project";
|
||||
export * from "./project-issue";
|
||||
export * from "./project-workspace";
|
||||
|
||||
Reference in New Issue
Block a user