-
Notifications
You must be signed in to change notification settings - Fork 6.2k
feat: Commit, Push and Create PR actions #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
39390c0
1768d31
98eb87f
533dbdf
3697765
e0df5d5
ae296e5
6fc632f
3c939e8
36bc85f
62cc55a
9accd1f
c643de8
5a0f5e9
95c8463
8f6cd24
b7af14a
dd92084
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| import { randomUUID } from "node:crypto"; | ||
| import fs from "node:fs/promises"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| import type { | ||
| CommitMessageGenerationInput, | ||
| CommitMessageGenerationResult, | ||
| PrContentGenerationInput, | ||
| PrContentGenerationResult, | ||
| TextGenerationService, | ||
| } from "./coreServices"; | ||
| import { runProcess } from "./processRunner"; | ||
|
|
||
| const COMMIT_OUTPUT_SCHEMA_JSON = { | ||
| type: "object", | ||
| properties: { | ||
| subject: { type: "string" }, | ||
| body: { type: "string" }, | ||
| }, | ||
| required: ["subject", "body"], | ||
| additionalProperties: false, | ||
| } as const; | ||
|
|
||
| const PR_OUTPUT_SCHEMA_JSON = { | ||
| type: "object", | ||
| properties: { | ||
| title: { type: "string" }, | ||
| body: { type: "string" }, | ||
| }, | ||
| required: ["title", "body"], | ||
| additionalProperties: false, | ||
| } as const; | ||
|
|
||
| function parseCommitOutput(raw: unknown): { subject: string; body: string } { | ||
| if (!raw || typeof raw !== "object") { | ||
| throw new Error("Codex returned a non-object commit message payload."); | ||
| } | ||
| const record = raw as Record<string, unknown>; | ||
| const subject = typeof record.subject === "string" ? record.subject.trim() : ""; | ||
| const body = typeof record.body === "string" ? record.body : ""; | ||
| if (subject.length === 0) { | ||
| throw new Error("Codex returned an empty commit subject."); | ||
| } | ||
| return { subject, body }; | ||
| } | ||
|
|
||
| function parsePrOutput(raw: unknown): { title: string; body: string } { | ||
| if (!raw || typeof raw !== "object") { | ||
| throw new Error("Codex returned a non-object PR payload."); | ||
| } | ||
| const record = raw as Record<string, unknown>; | ||
| const title = typeof record.title === "string" ? record.title.trim() : ""; | ||
| const body = typeof record.body === "string" ? record.body.trim() : ""; | ||
| if (title.length === 0 || body.length === 0) { | ||
| throw new Error("Codex returned an invalid PR title/body payload."); | ||
| } | ||
| return { title, body }; | ||
| } | ||
|
|
||
| function limitSection(value: string, maxChars: number): string { | ||
| if (value.length <= maxChars) return value; | ||
| const truncated = value.slice(0, maxChars); | ||
| return `${truncated}\n\n[truncated]`; | ||
| } | ||
|
|
||
| async function writeTempFile(prefix: string, content: string): Promise<string> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low
🤖 Prompt for AI |
||
| const filePath = path.join( | ||
| os.tmpdir(), | ||
| `t3code-${prefix}-${process.pid}-${randomUUID()}.tmp`, | ||
| ); | ||
| await fs.writeFile(filePath, content, "utf8"); | ||
| return filePath; | ||
| } | ||
|
|
||
| async function safeUnlink(filePath: string): Promise<void> { | ||
| try { | ||
| await fs.unlink(filePath); | ||
| } catch { | ||
| // Best-effort cleanup. | ||
| } | ||
| } | ||
|
|
||
| function sanitizeCommitSubject(raw: string): string { | ||
| const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | ||
| const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); | ||
| if (withoutTrailingPeriod.length === 0) { | ||
| return "Update project files"; | ||
| } | ||
|
|
||
| if (withoutTrailingPeriod.length <= 72) { | ||
| return withoutTrailingPeriod; | ||
| } | ||
| return withoutTrailingPeriod.slice(0, 72).trimEnd(); | ||
| } | ||
|
|
||
| function sanitizePrTitle(raw: string): string { | ||
| const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | ||
| if (singleLine.length > 0) { | ||
| return singleLine; | ||
| } | ||
| return "Update project changes"; | ||
| } | ||
|
|
||
| async function runCodexJson<T>({ | ||
| cwd, | ||
| prompt, | ||
| outputSchemaJson, | ||
| parse, | ||
| }: { | ||
| cwd: string; | ||
| prompt: string; | ||
| outputSchemaJson: object; | ||
| parse: (raw: unknown) => T; | ||
| }): Promise<T> { | ||
| const schemaPath = await writeTempFile( | ||
| "codex-schema", | ||
| JSON.stringify(outputSchemaJson), | ||
| ); | ||
| let outputPath: string | null = null; | ||
|
|
||
| try { | ||
| outputPath = await writeTempFile("codex-output", ""); | ||
|
|
||
| await runProcess( | ||
| "codex", | ||
| [ | ||
| "exec", | ||
| "--ephemeral", | ||
| "-s", | ||
| "read-only", | ||
| "--output-schema", | ||
| schemaPath, | ||
| "--output-last-message", | ||
| outputPath, | ||
| "-", | ||
| ], | ||
| { | ||
| cwd, | ||
| timeoutMs: 180_000, | ||
| stdin: prompt, | ||
| }, | ||
| ); | ||
|
|
||
| const rawOutput = (await fs.readFile(outputPath, "utf8")).trim(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High
- const rawOutput = (await fs.readFile(outputPath, "utf8")).trim();
+ const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
+ const stat = await fs.stat(outputPath);
+ if (stat.size > MAX_OUTPUT_BYTES) {
+ throw new Error(`Codex output exceeded size limit (${MAX_OUTPUT_BYTES} bytes).`);
+ }
+ const rawOutput = (await fs.readFile(outputPath, "utf8")).trim();
🤖 Prompt for AI |
||
| if (rawOutput.length === 0) { | ||
| throw new Error("Codex returned an empty response."); | ||
| } | ||
|
|
||
| let parsedJson: unknown; | ||
| try { | ||
| parsedJson = JSON.parse(rawOutput); | ||
| } catch { | ||
| throw new Error("Codex returned invalid JSON output."); | ||
| } | ||
|
|
||
| return parse(parsedJson); | ||
| } finally { | ||
| await Promise.all([ | ||
| safeUnlink(schemaPath), | ||
| ...(outputPath ? [safeUnlink(outputPath)] : []), | ||
| ]); | ||
| } | ||
| } | ||
|
|
||
| export class CodexTextGenerator implements TextGenerationService { | ||
| async generateCommitMessage( | ||
| input: CommitMessageGenerationInput, | ||
| ): Promise<CommitMessageGenerationResult> { | ||
| const prompt = [ | ||
| "You write concise git commit messages.", | ||
| "Return a JSON object with keys: subject, body.", | ||
| "Rules:", | ||
| "- subject must be imperative, <= 72 chars, and no trailing period", | ||
| "- body can be empty string or short bullet points", | ||
| "- capture the primary user-visible or developer-visible change", | ||
| "", | ||
| `Branch: ${input.branch ?? "(detached)"}`, | ||
| "", | ||
| "Staged files:", | ||
| limitSection(input.stagedSummary, 6_000), | ||
| "", | ||
| "Staged patch:", | ||
| limitSection(input.stagedPatch, 40_000), | ||
| ].join("\n"); | ||
|
|
||
| const generated = await runCodexJson({ | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, | ||
| parse: (raw) => parseCommitOutput(raw), | ||
| }); | ||
|
|
||
| return { | ||
| subject: sanitizeCommitSubject(generated.subject), | ||
| body: generated.body.trim(), | ||
| }; | ||
| } | ||
|
|
||
| async generatePrContent( | ||
| input: PrContentGenerationInput, | ||
| ): Promise<PrContentGenerationResult> { | ||
| const prompt = [ | ||
| "You write GitHub pull request content.", | ||
| "Return a JSON object with keys: title, body.", | ||
| "Rules:", | ||
| "- title should be concise and specific", | ||
| "- body must be markdown and include headings '## Summary' and '## Testing'", | ||
| "- under Summary, provide short bullet points", | ||
| "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | ||
| "", | ||
| `Base branch: ${input.baseBranch}`, | ||
| `Head branch: ${input.headBranch}`, | ||
| "", | ||
| "Commits:", | ||
| limitSection(input.commitSummary, 12_000), | ||
| "", | ||
| "Diff stat:", | ||
| limitSection(input.diffSummary, 12_000), | ||
| "", | ||
| "Diff patch:", | ||
| limitSection(input.diffPatch, 40_000), | ||
| ].join("\n"); | ||
|
|
||
| const generated = await runCodexJson({ | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, | ||
| parse: (raw) => parsePrOutput(raw), | ||
| }); | ||
|
|
||
| return { | ||
| title: sanitizePrTitle(generated.title), | ||
| body: generated.body.trim(), | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| export interface CommitMessageGenerationInput { | ||
| cwd: string; | ||
| branch: string | null; | ||
| stagedSummary: string; | ||
| stagedPatch: string; | ||
| } | ||
|
|
||
| export interface CommitMessageGenerationResult { | ||
| subject: string; | ||
| body: string; | ||
| } | ||
|
|
||
| export interface PrContentGenerationInput { | ||
| cwd: string; | ||
| baseBranch: string; | ||
| headBranch: string; | ||
| commitSummary: string; | ||
| diffSummary: string; | ||
| diffPatch: string; | ||
| } | ||
|
|
||
| export interface PrContentGenerationResult { | ||
| title: string; | ||
| body: string; | ||
| } | ||
|
|
||
| export interface TextGenerationService { | ||
| generateCommitMessage( | ||
| input: CommitMessageGenerationInput, | ||
| ): Promise<CommitMessageGenerationResult>; | ||
| generatePrContent( | ||
| input: PrContentGenerationInput, | ||
| ): Promise<PrContentGenerationResult>; | ||
| } | ||
|
|
Uh oh!
There was an error while loading. Please reload this page.