beflow 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ import { isAbsolute, resolve } from "node:path";
2
+
3
+ import { Glob, file, spawn } from "bun";
4
+
5
+ import type { PolicyDecision, PolicyRule, ResolvedPolicy } from "../model/types.ts";
6
+ import type { Exec } from "./worktree.ts";
7
+
8
+ /** The change context handed to the policy gate after a run produces a diff. */
9
+ export interface PolicyContext {
10
+ agent: string;
11
+ jobKind: string;
12
+ repo: string;
13
+ baseBranch: string;
14
+ changedFiles: string[];
15
+ issueKey: string;
16
+ }
17
+
18
+ export interface PolicyResult {
19
+ decision: PolicyDecision;
20
+ reason: string;
21
+ }
22
+
23
+ /**
24
+ * Injectable runner for the external command evaluator: spawns `argv` in `cwd`,
25
+ * pipes `stdin` to it, and returns the exit code with stdout/stderr. Tests fake
26
+ * this; production uses `defaultPolicyExec`.
27
+ */
28
+ export type PolicyExec = (
29
+ argv: string[],
30
+ cwd: string,
31
+ stdin: string,
32
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
33
+
34
+ /**
35
+ * Injectable file reader for the agentowners evaluator: returns the file text, or
36
+ * `undefined` when the file is absent. Tests fake this; production uses
37
+ * `defaultPolicyReader`.
38
+ */
39
+ export type PolicyReader = (path: string) => Promise<string | undefined>;
40
+
41
+ /** Most-restrictive-wins ordering: a lower rank beats a higher one. */
42
+ const DECISION_RANK: Record<PolicyDecision, number> = { block: 0, require_approval: 1, allow: 2 };
43
+
44
+ /**
45
+ * Changed files relative to the merge-base of `base` and HEAD. The three-dot form
46
+ * isolates the run's own changes from commits that landed on `base` in parallel.
47
+ */
48
+ export async function computeChangedFiles(cwd: string, base: string, exec: Exec): Promise<string[]> {
49
+ const result = await exec("git", ["-C", cwd, "diff", "--name-only", `${base}...HEAD`]);
50
+ if (result.code !== 0) {
51
+ throw new Error(`beflow: git diff failed (exit ${String(result.code)}): ${result.stderr.trim()}`);
52
+ }
53
+ return result.stdout
54
+ .split("\n")
55
+ .map((line) => line.trim())
56
+ .filter((line) => line.length > 0);
57
+ }
58
+
59
+ function ruleMatches(rule: { paths?: string[]; agent?: string }, context: PolicyContext): boolean {
60
+ if (rule.agent !== undefined && rule.agent !== context.agent) {
61
+ return false;
62
+ }
63
+ if (rule.paths === undefined) {
64
+ return true;
65
+ }
66
+ return rule.paths.some((pattern) => {
67
+ const glob = new Glob(pattern);
68
+ return context.changedFiles.some((file) => glob.match(file));
69
+ });
70
+ }
71
+
72
+ /** Most-restrictive-wins evaluation of a rule set against a change context. */
73
+ function evaluateRules(rules: PolicyRule[], context: PolicyContext): PolicyResult {
74
+ const matched = rules.filter((rule) => ruleMatches(rule, context));
75
+ if (matched.length === 0) {
76
+ return { decision: "allow", reason: "no policy rule matched" };
77
+ }
78
+ const winner = matched.reduce((best, rule) =>
79
+ DECISION_RANK[rule.decision] < DECISION_RANK[best.decision] ? rule : best,
80
+ );
81
+ const scope = winner.agent !== undefined ? ` agent=${winner.agent}` : "";
82
+ const paths = winner.paths !== undefined ? ` paths=${winner.paths.join(",")}` : "";
83
+ return { decision: winner.decision, reason: `rule decision=${winner.decision}${scope}${paths}` };
84
+ }
85
+
86
+ function evaluateGlobs(context: PolicyContext, policy: ResolvedPolicy): PolicyResult {
87
+ return evaluateRules(policy.rules ?? [], context);
88
+ }
89
+
90
+ /**
91
+ * Parse a CODEOWNERS-style AGENTOWNERS file into policy rules. Each non-blank,
92
+ * non-comment line is `<path-glob> <decision> [agent]`; `#` starts a comment.
93
+ * Fails closed: an invalid decision or a malformed line throws rather than being
94
+ * silently skipped, so a broken policy file never degrades to an allow.
95
+ */
96
+ export function parseAgentowners(text: string): PolicyRule[] {
97
+ const rules: PolicyRule[] = [];
98
+ for (const [index, line] of text.split("\n").entries()) {
99
+ const stripped = (line.split("#", 1)[0] ?? "").trim();
100
+ if (stripped.length === 0) {
101
+ continue;
102
+ }
103
+ const [glob, decision, agent, ...rest] = stripped.split(/\s+/);
104
+ if (glob === undefined || decision === undefined || rest.length > 0) {
105
+ throw new Error(`beflow: malformed AGENTOWNERS line ${String(index + 1)}: "${line.trim()}"`);
106
+ }
107
+ if (!isPolicyDecision(decision)) {
108
+ throw new Error(`beflow: invalid AGENTOWNERS decision on line ${String(index + 1)}: "${decision}"`);
109
+ }
110
+ rules.push({ decision, paths: [glob], ...(agent !== undefined ? { agent } : {}) });
111
+ }
112
+ return rules;
113
+ }
114
+
115
+ async function evaluateAgentowners(
116
+ context: PolicyContext,
117
+ policy: ResolvedPolicy,
118
+ cwd: string,
119
+ reader: PolicyReader,
120
+ ): Promise<PolicyResult> {
121
+ const configured = policy.agentownersPath ?? ".github/AGENTOWNERS";
122
+ const path = isAbsolute(configured) ? configured : resolve(cwd, configured);
123
+ const text = await reader(path);
124
+ if (text === undefined) {
125
+ return { decision: "allow", reason: `no AGENTOWNERS file at ${path}` };
126
+ }
127
+ return evaluateRules(parseAgentowners(text), context);
128
+ }
129
+
130
+ function isPolicyDecision(value: unknown): value is PolicyDecision {
131
+ return value === "block" || value === "require_approval" || value === "allow";
132
+ }
133
+
134
+ async function evaluateCommand(
135
+ context: PolicyContext,
136
+ policy: ResolvedPolicy,
137
+ exec: PolicyExec,
138
+ ): Promise<PolicyResult> {
139
+ if (policy.command === undefined || policy.command.length === 0) {
140
+ throw new Error("beflow: policy evaluator is 'command' but policy.command is missing");
141
+ }
142
+ const ran = await exec(policy.command, context.repo, JSON.stringify(context));
143
+ if (ran.exitCode !== 0) {
144
+ throw new Error(`beflow: policy command failed (exit ${String(ran.exitCode)}): ${ran.stderr.trim()}`);
145
+ }
146
+ let parsed: unknown;
147
+ try {
148
+ parsed = JSON.parse(ran.stdout);
149
+ } catch {
150
+ throw new Error(`beflow: policy command emitted non-JSON output: ${ran.stdout.trim()}`);
151
+ }
152
+ if (typeof parsed !== "object" || parsed === null) {
153
+ throw new Error(`beflow: policy command output is not an object: ${ran.stdout.trim()}`);
154
+ }
155
+ const { decision, reason } = parsed as { decision?: unknown; reason?: unknown };
156
+ if (!isPolicyDecision(decision)) {
157
+ throw new Error(`beflow: policy command returned an invalid decision: ${JSON.stringify(decision)}`);
158
+ }
159
+ return { decision, reason: typeof reason === "string" ? reason : "" };
160
+ }
161
+
162
+ /**
163
+ * Apply the resolved policy to a change context. `off` always allows; `globs`
164
+ * runs the rule set with most-restrictive-wins; `agentowners` runs the same engine
165
+ * over a CODEOWNERS-style file (missing file allows, malformed file throws);
166
+ * `command` delegates to an external evaluator and treats any engine failure as a
167
+ * hard error (never a silent allow).
168
+ */
169
+ export async function evaluatePolicy(
170
+ context: PolicyContext,
171
+ policy: ResolvedPolicy,
172
+ exec: PolicyExec,
173
+ cwd: string,
174
+ reader: PolicyReader = defaultPolicyReader,
175
+ ): Promise<PolicyResult> {
176
+ switch (policy.evaluator) {
177
+ case "off":
178
+ return { decision: "allow", reason: "policy disabled" };
179
+ case "globs":
180
+ return evaluateGlobs(context, policy);
181
+ case "agentowners":
182
+ return evaluateAgentowners(context, policy, cwd, reader);
183
+ case "command":
184
+ return evaluateCommand(context, policy, exec);
185
+ default: {
186
+ const exhaustive: never = policy.evaluator;
187
+ throw new Error(`beflow: unknown policy evaluator "${String(exhaustive)}"`);
188
+ }
189
+ }
190
+ }
191
+
192
+ /** Default `PolicyExec`: spawn `argv` in `cwd`, write `stdin`, capture stdout/stderr. */
193
+ export async function defaultPolicyExec(
194
+ argv: string[],
195
+ cwd: string,
196
+ stdin: string,
197
+ ): Promise<{ exitCode: number; stdout: string; stderr: string }> {
198
+ const proc = spawn(argv, { cwd, stderr: "pipe", stdin: new TextEncoder().encode(stdin), stdout: "pipe" });
199
+ const [stdout, stderr, exitCode] = await Promise.all([
200
+ new Response(proc.stdout).text(),
201
+ new Response(proc.stderr).text(),
202
+ proc.exited,
203
+ ]);
204
+ return { exitCode, stderr, stdout };
205
+ }
206
+
207
+ /** Default `PolicyReader`: read `path` via `Bun.file`, returning `undefined` when absent. */
208
+ export async function defaultPolicyReader(path: string): Promise<string | undefined> {
209
+ const handle = file(path);
210
+ if (!(await handle.exists())) {
211
+ return undefined;
212
+ }
213
+ return handle.text();
214
+ }
package/src/core/pr.ts ADDED
@@ -0,0 +1,169 @@
1
+ import type { Exec } from "./worktree.ts";
2
+
3
+ export interface PrRef {
4
+ number: number;
5
+ url: string;
6
+ }
7
+
8
+ const AUTO_BASE = "auto";
9
+
10
+ // stderr substrings gh emits when the requested transition is already done.
11
+ // These are benign (idempotent no-ops), not real failures.
12
+ const ALREADY_READY = /already.*(ready|open for review)|not.*draft/i;
13
+ const ALREADY_CLOSED = /already closed|could not resolve to a pullrequest|no pull requests found/i;
14
+
15
+ async function runGh(args: string[], exec: Exec): Promise<string> {
16
+ const result = await exec("gh", args);
17
+ if (result.code !== 0) {
18
+ throw new Error(`beflow: gh ${args.join(" ")} failed (exit ${String(result.code)}): ${result.stderr.trim()}`);
19
+ }
20
+ return result.stdout;
21
+ }
22
+
23
+ async function runGitIn(cwd: string, args: string[], exec: Exec): Promise<string> {
24
+ const fullArgs = ["-C", cwd, ...args];
25
+ const result = await exec("git", fullArgs);
26
+ if (result.code !== 0) {
27
+ throw new Error(
28
+ `beflow: git ${fullArgs.join(" ")} failed (exit ${String(result.code)}): ${result.stderr.trim()}`,
29
+ );
30
+ }
31
+ return result.stdout;
32
+ }
33
+
34
+ function prSelector(pr: PrRef | number | string): string {
35
+ if (typeof pr === "object") {
36
+ return String(pr.number);
37
+ }
38
+ return typeof pr === "number" ? String(pr) : pr;
39
+ }
40
+
41
+ /**
42
+ * Resolve the base branch. An explicit value is returned verbatim; the sentinel
43
+ * `"auto"` triggers detection of the repo's default branch via gh.
44
+ */
45
+ export async function detectBaseBranch(repo: string, baseBranch: string, exec: Exec): Promise<string> {
46
+ if (baseBranch !== AUTO_BASE) {
47
+ return baseBranch;
48
+ }
49
+ const stdout = await runGh(
50
+ ["repo", "view", repo, "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"],
51
+ exec,
52
+ );
53
+ return stdout.trim();
54
+ }
55
+
56
+ /**
57
+ * True iff the worktree's HEAD carries commits that `base` lacks — the
58
+ * "did the agent actually produce work" check that keeps PR-open a no-op
59
+ * when nothing changed.
60
+ */
61
+ export async function hasCommits(cwd: string, base: string, exec: Exec): Promise<boolean> {
62
+ const stdout = await runGitIn(cwd, ["rev-list", "--count", `${base}..HEAD`], exec);
63
+ return Number.parseInt(stdout.trim(), 10) > 0;
64
+ }
65
+
66
+ function parsePrRef(stdout: string): PrRef {
67
+ const trimmed = stdout.trim();
68
+ let parsed: unknown;
69
+ try {
70
+ parsed = JSON.parse(trimmed);
71
+ } catch {
72
+ // `gh pr create` prints the bare PR URL on success.
73
+ const number = Number.parseInt(trimmed.split("/").at(-1) ?? "", 10);
74
+ if (Number.isNaN(number)) {
75
+ throw new Error(`beflow: could not parse PR from gh output: ${trimmed}`);
76
+ }
77
+ return { number, url: trimmed };
78
+ }
79
+ const obj: Record<string, unknown> = typeof parsed === "object" && parsed !== null ? { ...parsed } : {};
80
+ const url = typeof obj.url === "string" ? obj.url : "";
81
+ const number = typeof obj.number === "number" ? obj.number : Number.NaN;
82
+ if (url === "" || Number.isNaN(number)) {
83
+ throw new Error(`beflow: could not parse PR from gh output: ${trimmed}`);
84
+ }
85
+ return { number, url };
86
+ }
87
+
88
+ /**
89
+ * Open a draft PR for `head` against `base`. Idempotent: if a PR for that head
90
+ * already exists, the existing one is returned instead of failing.
91
+ */
92
+ export async function openDraftPr(
93
+ args: { repo: string; head: string; base: string; title: string; body: string; cwd: string },
94
+ exec: Exec,
95
+ ): Promise<PrRef> {
96
+ const created = await exec("gh", [
97
+ "pr",
98
+ "create",
99
+ "--repo",
100
+ args.repo,
101
+ "--draft",
102
+ "--base",
103
+ args.base,
104
+ "--head",
105
+ args.head,
106
+ "--title",
107
+ args.title,
108
+ "--body",
109
+ args.body,
110
+ ]);
111
+ if (created.code === 0) {
112
+ return parsePrRef(created.stdout);
113
+ }
114
+ const existing = await exec("gh", ["pr", "view", args.head, "--repo", args.repo, "--json", "url,number"]);
115
+ if (existing.code === 0) {
116
+ return parsePrRef(existing.stdout);
117
+ }
118
+ throw new Error(
119
+ `beflow: gh pr create (head ${args.head}) failed (exit ${String(created.code)}): ${created.stderr.trim()}`,
120
+ );
121
+ }
122
+
123
+ /** Mark a PR ready for review. Tolerant of a PR that is already ready. */
124
+ export async function markReady(pr: PrRef | number | string, repo: string, exec: Exec): Promise<void> {
125
+ const result = await exec("gh", ["pr", "ready", prSelector(pr), "--repo", repo]);
126
+ if (result.code === 0 || ALREADY_READY.test(result.stderr)) {
127
+ return;
128
+ }
129
+ throw new Error(`beflow: gh pr ready failed (exit ${String(result.code)}): ${result.stderr.trim()}`);
130
+ }
131
+
132
+ /** Edit a PR's title and/or body. Only the provided fields are passed to gh. */
133
+ export async function editPr(
134
+ pr: PrRef | number | string,
135
+ repo: string,
136
+ fields: { title?: string; body?: string },
137
+ exec: Exec,
138
+ ): Promise<void> {
139
+ if (fields.title === undefined && fields.body === undefined) {
140
+ return;
141
+ }
142
+ const args = ["pr", "edit", prSelector(pr), "--repo", repo];
143
+ if (fields.title !== undefined) {
144
+ args.push("--title", fields.title);
145
+ }
146
+ if (fields.body !== undefined) {
147
+ args.push("--body", fields.body);
148
+ }
149
+ await runGh(args, exec);
150
+ }
151
+
152
+ /**
153
+ * Close a PR and delete its head branch. Idempotent: an already-closed PR or an
154
+ * already-gone branch is tolerated; the local branch is best-effort deleted too.
155
+ */
156
+ export async function closePrAndDeleteBranch(
157
+ pr: PrRef | number | string,
158
+ repo: string,
159
+ branch: string,
160
+ cwd: string,
161
+ exec: Exec,
162
+ ): Promise<void> {
163
+ const closed = await exec("gh", ["pr", "close", prSelector(pr), "--repo", repo, "--delete-branch"]);
164
+ if (closed.code !== 0 && !ALREADY_CLOSED.test(closed.stderr)) {
165
+ throw new Error(`beflow: gh pr close failed (exit ${String(closed.code)}): ${closed.stderr.trim()}`);
166
+ }
167
+ // Best-effort local branch cleanup: a missing branch is the desired end state.
168
+ await exec("git", ["-C", cwd, "branch", "-D", branch]);
169
+ }
@@ -133,9 +133,33 @@ export function renderTask(set: PromptSet, issue: Issue, repo: string): string {
133
133
  return renderTemplate("task", set.task, buildPromptContext(issue, repo));
134
134
  }
135
135
 
136
- export function renderContract(set: PromptSet, jobKind: JobKind, issue: Issue, repo: string): string {
136
+ const AGENT_OWNED_PR_INSTRUCTION = "commit, push, and open a pull request with `gh`, then put the PR URL in `prUrl`.";
137
+ const BEFLOW_OWNED_PR_INSTRUCTION =
138
+ "commit and push your branch. Do NOT run `gh pr create`, `gh pr edit`, or open or update any pull request — beflow will open the PR from your pushed branch.";
139
+
140
+ const AGENT_OWNED_PR_CONTINUATION_INSTRUCTION = "UPDATE the existing pull request — do not open a new one.";
141
+ const BEFLOW_OWNED_PR_CONTINUATION_INSTRUCTION =
142
+ "push your changes. The existing pull request updates automatically — do NOT run `gh pr create` or `gh pr edit`.";
143
+
144
+ export function renderContract(
145
+ set: PromptSet,
146
+ jobKind: JobKind,
147
+ issue: Issue,
148
+ repo: string,
149
+ beflowOwnsPr = false,
150
+ ): string {
137
151
  const ctx = buildPromptContext(issue, repo);
138
- return `${renderTemplate(jobKind, set[jobKind], ctx)}\n\n${renderTemplate("report", set.report, ctx)}`;
152
+ const promptCtx =
153
+ jobKind === "implement"
154
+ ? {
155
+ ...ctx,
156
+ pr_continuation_instruction: beflowOwnsPr
157
+ ? BEFLOW_OWNED_PR_CONTINUATION_INSTRUCTION
158
+ : AGENT_OWNED_PR_CONTINUATION_INSTRUCTION,
159
+ pr_instruction: beflowOwnsPr ? BEFLOW_OWNED_PR_INSTRUCTION : AGENT_OWNED_PR_INSTRUCTION,
160
+ }
161
+ : ctx;
162
+ return `${renderTemplate(jobKind, set[jobKind], promptCtx)}\n\n${renderTemplate("report", set.report, ctx)}`;
139
163
  }
140
164
 
141
165
  export function renderReviewContract(set: PromptSet, issue: Issue, repo: string): string {
@@ -21,7 +21,7 @@ const MAX_OUTPUT_CHARS = 16000;
21
21
  */
22
22
  export function resolveQualityGate(config: Config, registry: Registry, projectKey: string): string[] {
23
23
  const projectCommands = registry.projects[projectKey]?.qualityGate?.commands;
24
- const globalCommands = config.defaults.qualityGate?.commands;
24
+ const globalCommands = config.qualityGate?.commands;
25
25
  return projectCommands ?? globalCommands ?? [];
26
26
  }
27
27
 
@@ -71,11 +71,11 @@ export type ReviewSha = (prUrl: string) => Promise<string | undefined>;
71
71
 
72
72
  // Project-over-default resolution of the per-project review toggles.
73
73
  export function resolveReviewEnabled(config: Config, registry: Registry, projectKey: string): boolean {
74
- return registry.projects[projectKey]?.review?.enabled ?? config.defaults.review?.enabled ?? false;
74
+ return registry.projects[projectKey]?.review?.enabled ?? config.review?.enabled ?? false;
75
75
  }
76
76
 
77
77
  export function resolveReviewPostToPr(config: Config, registry: Registry, projectKey: string): boolean {
78
- return registry.projects[projectKey]?.review?.postToPr ?? config.defaults.review?.postToPr ?? false;
78
+ return registry.projects[projectKey]?.review?.postToPr ?? config.review?.postToPr ?? false;
79
79
  }
80
80
 
81
81
  function projectKeyOf(issueKey: string): string {