@arungeorgesaji/assembly 0.1.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.
package/src/github.js ADDED
@@ -0,0 +1,261 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ import { getRunDir, readRun } from "./run-store.js";
7
+ import { validateChangedFilesWithinScope } from "./scope.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export async function createGitHubPullRequest(runId, { rootDir = process.cwd(), exec = execFileAsync } = {}) {
12
+ const run = await readRun(runId, rootDir);
13
+ const changedFiles = getRunOwnedChangedFiles(run);
14
+ await validateRunReadyForPullRequest(run, changedFiles, rootDir, exec);
15
+
16
+ const startingBranch = await getCurrentBranch(rootDir, exec);
17
+ const branchName = `assembly/${runId}`;
18
+ const title = `Assembly: ${run.request.request}`;
19
+ const reportPath = path.join(getRunDir(runId, rootDir), "final-report.md");
20
+ const report = await readFile(reportPath, "utf8");
21
+ const body = buildGitHubPullRequestBody(report, {
22
+ runId,
23
+ branchName,
24
+ });
25
+ const bodyPath = path.join(getRunDir(runId, rootDir), "github-pr-body.md");
26
+ await writeFile(bodyPath, body);
27
+
28
+ try {
29
+ await exec("git", ["checkout", "-B", branchName], { cwd: rootDir });
30
+ await exec("git", ["add", "--", ...changedFiles], { cwd: rootDir });
31
+ await exec("git", ["commit", "-m", title], { cwd: rootDir });
32
+ await exec("git", ["push", "-u", "origin", branchName], { cwd: rootDir });
33
+
34
+ const { stdout } = await exec(
35
+ "gh",
36
+ ["pr", "create", "--title", title, "--body-file", bodyPath],
37
+ { cwd: rootDir },
38
+ );
39
+
40
+ return {
41
+ runId,
42
+ branchName,
43
+ title,
44
+ url: stdout.trim(),
45
+ restoredBranch: startingBranch,
46
+ };
47
+ } finally {
48
+ if (startingBranch) {
49
+ await exec("git", ["checkout", startingBranch], { cwd: rootDir });
50
+ }
51
+ }
52
+ }
53
+
54
+ export async function updateGitHubPullRequestFromRun(
55
+ runId,
56
+ { rootDir = process.cwd(), branchName, prNumber, commentUrl, exec = execFileAsync } = {},
57
+ ) {
58
+ const run = await readRun(runId, rootDir);
59
+ const changedFiles = getRunOwnedChangedFiles(run);
60
+ await validateRunReadyForPullRequest(run, changedFiles, rootDir, exec);
61
+
62
+ if (!branchName) {
63
+ throw new Error("branchName is required to update a pull request");
64
+ }
65
+
66
+ await exec("git", ["add", "--", ...changedFiles], { cwd: rootDir });
67
+ await exec("git", ["commit", "-m", `Assembly follow-up: ${getRunTitleText(run)}`], { cwd: rootDir });
68
+ await exec("git", ["push", "origin", branchName], { cwd: rootDir });
69
+
70
+ const reportPath = path.join(getRunDir(runId, rootDir), "final-report.md");
71
+ const report = await readFile(reportPath, "utf8");
72
+ const bodyPath = path.join(getRunDir(runId, rootDir), "github-pr-body.md");
73
+ const prBody = buildGitHubPullRequestBody(report, {
74
+ runId,
75
+ branchName,
76
+ parentRunId: run.request.parentRunId,
77
+ });
78
+ await writeFile(bodyPath, prBody);
79
+ await exec("gh", ["pr", "edit", String(prNumber ?? branchName), "--body-file", bodyPath], { cwd: rootDir });
80
+
81
+ const body = [`Assembly handled this feedback with run ${runId}.`, "", report].join("\n");
82
+
83
+ if (commentUrl) {
84
+ await exec("gh", ["pr", "comment", "--body", body], { cwd: rootDir });
85
+ }
86
+
87
+ return {
88
+ runId,
89
+ branchName,
90
+ prNumber,
91
+ commentUrl,
92
+ };
93
+ }
94
+
95
+ function getRunTitleText(run) {
96
+ const title = run.request.followUpFeedback ?? run.request.request;
97
+ if (typeof title === "string") {
98
+ return title;
99
+ }
100
+ return JSON.stringify(title);
101
+ }
102
+
103
+ export async function validateRunReadyForPullRequest(run, changedFiles, rootDir, exec) {
104
+ if (run.state.status !== "complete") {
105
+ throw new Error(`run ${run.state.runId} must be complete before creating a pull request`);
106
+ }
107
+
108
+ if (changedFiles.length === 0) {
109
+ throw new Error(`run ${run.state.runId} has no owned changed files to commit`);
110
+ }
111
+
112
+ const scopeErrors = validateRunChangedFileScopes(run, changedFiles);
113
+ if (scopeErrors.length > 0) {
114
+ throw new Error(scopeErrors.join("\n"));
115
+ }
116
+
117
+ if (hasFailedVerification(run)) {
118
+ throw new Error(`run ${run.state.runId} has failed verification`);
119
+ }
120
+
121
+ if (!hasCompletedReview(run)) {
122
+ throw new Error(`run ${run.state.runId} does not have a completed review`);
123
+ }
124
+
125
+ const dirtyFiles = await getDirtyFiles(rootDir, exec);
126
+ const unrelatedDirtyFiles = dirtyFiles.filter((file) => !changedFiles.includes(file));
127
+ if (unrelatedDirtyFiles.length > 0) {
128
+ throw new Error(`working tree has unrelated changes: ${unrelatedDirtyFiles.join(", ")}`);
129
+ }
130
+
131
+ const finalDiffFiles = await getFinalDiffFiles(rootDir, exec);
132
+ const unownedDiffFiles = finalDiffFiles.filter((file) => !changedFiles.includes(file));
133
+ if (unownedDiffFiles.length > 0) {
134
+ throw new Error(`final diff includes files not owned by run ${run.state.runId}: ${unownedDiffFiles.join(", ")}`);
135
+ }
136
+ }
137
+
138
+ export function getRunOwnedChangedFiles(run) {
139
+ const files = new Set();
140
+ for (const event of run.events) {
141
+ if (!["task.complete", "files.updated", "patch.applied"].includes(event.type)) {
142
+ continue;
143
+ }
144
+ for (const file of event.data?.changedFiles ?? []) {
145
+ files.add(file);
146
+ }
147
+ }
148
+ return [...files].sort();
149
+ }
150
+
151
+ function validateRunChangedFileScopes(run, changedFiles) {
152
+ const errors = [];
153
+ const taskById = new Map(run.plan.tasks.map((task) => [task.id, task]));
154
+
155
+ for (const event of run.events) {
156
+ if (!["task.complete", "files.updated", "patch.applied"].includes(event.type)) {
157
+ continue;
158
+ }
159
+
160
+ const task = taskById.get(event.taskId);
161
+ if (!task) {
162
+ continue;
163
+ }
164
+
165
+ const eventChangedFiles = (event.data?.changedFiles ?? []).filter((file) => changedFiles.includes(file));
166
+ errors.push(...validateChangedFilesWithinScope(task, eventChangedFiles));
167
+ }
168
+
169
+ return errors;
170
+ }
171
+
172
+ function hasFailedVerification(run) {
173
+ return run.events
174
+ .filter((event) => event.type === "verification.completed")
175
+ .some((event) => event.data.results.some((result) => result.exitCode !== 0));
176
+ }
177
+
178
+ function hasCompletedReview(run) {
179
+ return run.events.some((event) => {
180
+ if (event.type !== "task.complete") {
181
+ return false;
182
+ }
183
+ const task = run.plan.tasks.find((candidate) => candidate.id === event.taskId);
184
+ return task?.owner === "review-agent" && event.data?.status === "complete";
185
+ });
186
+ }
187
+
188
+ async function getDirtyFiles(rootDir, exec) {
189
+ const { stdout } = await exec("git", ["status", "--porcelain"], { cwd: rootDir });
190
+ return stdout
191
+ .split("\n")
192
+ .map((line) => line.trimEnd())
193
+ .filter(Boolean)
194
+ .flatMap((line) => parsePorcelainChangedFiles(line))
195
+ .sort();
196
+ }
197
+
198
+ async function getFinalDiffFiles(rootDir, exec) {
199
+ const { stdout } = await exec("git", ["diff", "--name-only", "HEAD"], { cwd: rootDir });
200
+ return stdout
201
+ .split("\n")
202
+ .map((line) => line.trim())
203
+ .filter(Boolean)
204
+ .sort();
205
+ }
206
+
207
+ function parsePorcelainChangedFiles(line) {
208
+ const pathText = line.slice(3);
209
+ if (line.startsWith("R ") || line.startsWith(" R") || line.startsWith("RM") || line.startsWith("AM")) {
210
+ const [from, to] = pathText.split(" -> ");
211
+ return [from, to].filter(Boolean);
212
+ }
213
+ return [pathText];
214
+ }
215
+
216
+ export async function getGitHubComment(commentId, { rootDir = process.cwd(), exec = execFileAsync } = {}) {
217
+ const { stdout } = await exec(
218
+ "gh",
219
+ ["api", `repos/{owner}/{repo}/issues/comments/${commentId}`],
220
+ { cwd: rootDir },
221
+ );
222
+ const comment = JSON.parse(stdout);
223
+ return {
224
+ id: String(comment.id),
225
+ body: comment.body,
226
+ url: comment.html_url,
227
+ };
228
+ }
229
+
230
+ export async function getGitHubPullRequest(prNumber, { rootDir = process.cwd(), exec = execFileAsync } = {}) {
231
+ const { stdout } = await exec(
232
+ "gh",
233
+ ["pr", "view", String(prNumber), "--json", "body,headRefName"],
234
+ { cwd: rootDir },
235
+ );
236
+ const pr = JSON.parse(stdout);
237
+ return {
238
+ number: Number(prNumber),
239
+ body: pr.body ?? "",
240
+ branchName: pr.headRefName,
241
+ runId: extractAssemblyMetadata(pr.body ?? "").runId,
242
+ };
243
+ }
244
+
245
+ export function extractAssemblyMetadata(body) {
246
+ const metadata = {};
247
+ for (const match of String(body ?? "").matchAll(/<!--\s*assembly:([a-zA-Z0-9_-]+)=([^>]+?)\s*-->/g)) {
248
+ metadata[match[1]] = match[2].trim();
249
+ }
250
+ return metadata;
251
+ }
252
+
253
+ export function buildGitHubPullRequestBody(body, metadata) {
254
+ const lines = Object.entries(metadata).map(([key, value]) => `<!-- assembly:${key}=${value} -->`);
255
+ return [...lines, "", body].join("\n");
256
+ }
257
+
258
+ async function getCurrentBranch(rootDir, exec) {
259
+ const { stdout } = await exec("git", ["branch", "--show-current"], { cwd: rootDir });
260
+ return stdout.trim();
261
+ }
package/src/init.js ADDED
@@ -0,0 +1,91 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const DEFAULT_ENV = [
5
+ "ASSEMBLY_AGENT_PROVIDER=stub",
6
+ "ASSEMBLY_APPROVAL_MODE=auto",
7
+ "OPENAI_API_KEY=",
8
+ "OPENAI_MODEL=gpt-4.1-mini",
9
+ "GITHUB_WEBHOOK_SECRET=",
10
+ "SLACK_SIGNING_SECRET=",
11
+ "SLACK_BOT_TOKEN=",
12
+ "",
13
+ ].join("\n");
14
+
15
+ export async function initializeAssembly({ rootDir = process.cwd(), force = false } = {}) {
16
+ await mkdir(path.join(rootDir, ".assembly"), { recursive: true });
17
+
18
+ const envPath = path.join(rootDir, ".env");
19
+ const existingEnv = await readFile(envPath, "utf8").catch((error) => {
20
+ if (error.code === "ENOENT") {
21
+ return null;
22
+ }
23
+ throw error;
24
+ });
25
+
26
+ if (existingEnv !== null && !force) {
27
+ return {
28
+ rootDir,
29
+ createdEnv: false,
30
+ createdStateDir: true,
31
+ nextSteps: [
32
+ ".env already exists; no values were overwritten.",
33
+ "Run `assembly doctor` to see missing setup.",
34
+ ],
35
+ };
36
+ }
37
+
38
+ const envContents = existingEnv === null ? DEFAULT_ENV : mergeEnvDefaults(existingEnv);
39
+ await writeFile(envPath, envContents);
40
+
41
+ return {
42
+ rootDir,
43
+ createdEnv: existingEnv === null,
44
+ updatedEnv: existingEnv !== null,
45
+ createdStateDir: true,
46
+ nextSteps: [
47
+ "Fill in .env values for OpenAI, GitHub webhooks, and Slack.",
48
+ "Run `assembly doctor` to verify setup.",
49
+ "Run `assembly webhook --port 3000` when doctor passes.",
50
+ ],
51
+ };
52
+ }
53
+
54
+ export function formatInitResult(result) {
55
+ const lines = [
56
+ `Initialized Assembly in ${result.rootDir}`,
57
+ result.createdEnv ? "Created .env" : result.updatedEnv ? "Updated .env with missing defaults" : "Kept existing .env",
58
+ "Ensured .assembly/ exists",
59
+ "",
60
+ "Next steps:",
61
+ ...result.nextSteps.map((step) => `- ${step}`),
62
+ "",
63
+ ];
64
+ return lines.join("\n");
65
+ }
66
+
67
+ function mergeEnvDefaults(existingEnv) {
68
+ const existingKeys = new Set(
69
+ existingEnv
70
+ .split(/\r?\n/)
71
+ .map((line) => line.trim())
72
+ .filter((line) => line && !line.startsWith("#") && line.includes("="))
73
+ .map((line) => line.slice(0, line.indexOf("=")).trim()),
74
+ );
75
+ const missingLines = DEFAULT_ENV
76
+ .split("\n")
77
+ .filter((line) => {
78
+ if (!line.trim()) {
79
+ return false;
80
+ }
81
+ const key = line.slice(0, line.indexOf("=")).trim();
82
+ return !existingKeys.has(key);
83
+ });
84
+
85
+ if (missingLines.length === 0) {
86
+ return existingEnv.endsWith("\n") ? existingEnv : `${existingEnv}\n`;
87
+ }
88
+
89
+ const separator = existingEnv.endsWith("\n") ? "" : "\n";
90
+ return `${existingEnv}${separator}\n# Assembly defaults\n${missingLines.join("\n")}\n`;
91
+ }
@@ -0,0 +1,97 @@
1
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const JOBS_DIR = ".assembly/jobs";
5
+
6
+ export function createJobId(date = new Date()) {
7
+ const timestamp = date.toISOString().replaceAll(":", "").replace(/\.\d{3}Z$/, "Z");
8
+ const suffix = Math.random().toString(36).slice(2, 8);
9
+ return `${timestamp}-${suffix}`;
10
+ }
11
+
12
+ export async function enqueueJob(job, rootDir = process.cwd()) {
13
+ const jobId = job.id ?? createJobId();
14
+ const queuedJob = {
15
+ id: jobId,
16
+ status: "queued",
17
+ createdAt: new Date().toISOString(),
18
+ updatedAt: new Date().toISOString(),
19
+ ...job,
20
+ id: jobId,
21
+ };
22
+ await writeJob(queuedJob, rootDir);
23
+ return queuedJob;
24
+ }
25
+
26
+ export async function readJob(jobId, rootDir = process.cwd()) {
27
+ return JSON.parse(await readFile(getJobPath(jobId, rootDir), "utf8"));
28
+ }
29
+
30
+ export async function listJobs(rootDir = process.cwd()) {
31
+ let entries;
32
+ try {
33
+ entries = await readdir(path.join(rootDir, JOBS_DIR));
34
+ } catch (error) {
35
+ if (error.code === "ENOENT") {
36
+ return [];
37
+ }
38
+ throw error;
39
+ }
40
+
41
+ const jobs = await Promise.all(
42
+ entries
43
+ .filter((entry) => entry.endsWith(".json"))
44
+ .map(async (entry) => JSON.parse(await readFile(path.join(rootDir, JOBS_DIR, entry), "utf8"))),
45
+ );
46
+ return jobs.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
47
+ }
48
+
49
+ export async function findJobByDelivery(delivery, rootDir = process.cwd()) {
50
+ if (!delivery) {
51
+ return null;
52
+ }
53
+ const jobs = await listJobs(rootDir);
54
+ return jobs.find((job) => job.delivery === delivery) ?? null;
55
+ }
56
+
57
+ export async function updateJob(jobId, updates, rootDir = process.cwd()) {
58
+ const job = await readJob(jobId, rootDir);
59
+ const updatedJob = {
60
+ ...job,
61
+ ...updates,
62
+ updatedAt: new Date().toISOString(),
63
+ };
64
+ await writeJob(updatedJob, rootDir);
65
+ return updatedJob;
66
+ }
67
+
68
+ export async function resetJobForRetry(jobId, rootDir = process.cwd()) {
69
+ const job = await readJob(jobId, rootDir);
70
+ const retryCount = (job.retryCount ?? 0) + 1;
71
+ const retriedAt = new Date().toISOString();
72
+ const resetJob = {
73
+ ...job,
74
+ status: "queued",
75
+ retryCount,
76
+ retriedAt,
77
+ updatedAt: retriedAt,
78
+ };
79
+
80
+ delete resetJob.startedAt;
81
+ delete resetJob.completedAt;
82
+ delete resetJob.failedAt;
83
+ delete resetJob.error;
84
+ delete resetJob.result;
85
+
86
+ await writeJob(resetJob, rootDir);
87
+ return resetJob;
88
+ }
89
+
90
+ async function writeJob(job, rootDir) {
91
+ await mkdir(path.join(rootDir, JOBS_DIR), { recursive: true });
92
+ await writeFile(getJobPath(job.id, rootDir), `${JSON.stringify(job, null, 2)}\n`);
93
+ }
94
+
95
+ function getJobPath(jobId, rootDir) {
96
+ return path.join(rootDir, JOBS_DIR, `${jobId}.json`);
97
+ }