PranitInitial commit439f83c2 months agoHistory
278 lines · 9.5 KB · Zig
RawDownload
const std = @import("std");const io_mod = @import("../shared/io.zig"); const Allocator = std.mem.Allocator; pub const Workflow = enum {    pull_request,    issue,}; pub const Draft = struct {    title: []u8,    body: []u8,     pub fn deinit(self: Draft, alloc: Allocator) void {        alloc.free(self.title);        alloc.free(self.body);    }}; pub const PublishResult = struct {    ok: bool,    text: []u8,     pub fn deinit(self: PublishResult, alloc: Allocator) void {        alloc.free(self.text);    }}; const CompletedProcess = struct {    term: std.process.Child.Term,    stdout: []u8,    stderr: []u8,}; pub fn parseDraft(alloc: Allocator, text: []const u8) !Draft {    const trimmed = std.mem.trim(u8, text, " \t\r\n");    if (trimmed.len == 0) return error.InvalidGithubDraft;     const first_break = std.mem.findScalar(u8, trimmed, '\n') orelse {        const title = try alloc.dupe(u8, trimmed);        errdefer alloc.free(title);        const body = try alloc.dupe(u8, "");        return .{            .title = title,            .body = body,        };    };     const title_slice = std.mem.trim(u8, trimmed[0..first_break], " \t\r\n");    if (title_slice.len == 0) return error.InvalidGithubDraft;     const body_slice = std.mem.trimStart(u8, trimmed[first_break + 1 ..], " \t\r\n");    const title = try alloc.dupe(u8, title_slice);    errdefer alloc.free(title);    const body = try alloc.dupe(u8, body_slice);    return .{        .title = title,        .body = body,    };} pub fn publish(alloc: Allocator, workflow: Workflow, draft: Draft) !PublishResult {    var argv = try buildPublishArgv(alloc, workflow, draft.title, draft.body);    defer argv.deinit(alloc);     const result = runPublishCommand(alloc, argv.items) catch |err| {        return publishResultFromRunError(alloc, err);    };    return publishResultFromProcess(alloc, result);} fn buildPublishArgv(alloc: Allocator, workflow: Workflow, title: []const u8, body: []const u8) !std.ArrayList([]const u8) {    var argv = std.ArrayList([]const u8).empty;    errdefer argv.deinit(alloc);     try argv.append(alloc, "gh");    try argv.append(alloc, switch (workflow) {        .pull_request => "pr",        .issue => "issue",    });    try argv.append(alloc, "create");    try argv.append(alloc, "--title");    try argv.append(alloc, title);    try argv.append(alloc, "--body");    try argv.append(alloc, body);    return argv;} fn runPublishCommand(alloc: Allocator, argv: []const []const u8) !CompletedProcess {    const result = try std.process.run(alloc, io_mod.getIo(), .{        .argv = argv,    });    return .{        .term = result.term,        .stdout = result.stdout,        .stderr = result.stderr,    };} fn publishResultFromRunError(alloc: Allocator, err: anyerror) !PublishResult {    return switch (err) {        error.FileNotFound => .{ .ok = false, .text = try alloc.dupe(u8, "gh CLI not found in PATH") },        else => err,    };} fn publishResultFromProcess(alloc: Allocator, result: CompletedProcess) !PublishResult {    defer alloc.free(result.stderr);     if (result.term.exited != 0) {        defer alloc.free(result.stdout);        const stderr_trimmed = std.mem.trim(u8, result.stderr, " \t\r\n");        if (stderr_trimmed.len > 0) {            return .{ .ok = false, .text = try alloc.dupe(u8, stderr_trimmed) };        }        return .{ .ok = false, .text = try alloc.dupe(u8, "gh command failed") };    }     const stdout_trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");    if (stdout_trimmed.len == 0) {        alloc.free(result.stdout);        return .{ .ok = true, .text = try alloc.dupe(u8, "created successfully") };    }    if (stdout_trimmed.len == result.stdout.len) {        return .{ .ok = true, .text = result.stdout };    }     const text = alloc.dupe(u8, stdout_trimmed) catch |err| {        alloc.free(result.stdout);        return err;    };    alloc.free(result.stdout);    return .{ .ok = true, .text = text };} test "parse draft extracts title and body" {    const draft = try parseDraft(std.testing.allocator, "Title\n\n## Summary\nhello");    defer draft.deinit(std.testing.allocator);     try std.testing.expectEqualStrings("Title", draft.title);    try std.testing.expectEqualStrings("## Summary\nhello", draft.body);} test "parse draft rejects whitespace-only input" {    try std.testing.expectError(error.InvalidGithubDraft, parseDraft(std.testing.allocator, " \n\t "));} test "parse draft accepts title-only input" {    const draft = try parseDraft(std.testing.allocator, "Title only");    defer draft.deinit(std.testing.allocator);     try std.testing.expectEqualStrings("Title only", draft.title);    try std.testing.expectEqualStrings("", draft.body);} test "parse draft trims outer whitespace and preserves body markdown" {    const draft = try parseDraft(        std.testing.allocator,        " \r\n  Title with space  \r\n\r\n  - one\r\n  - two\r\n\n",    );    defer draft.deinit(std.testing.allocator);     try std.testing.expectEqualStrings("Title with space", draft.title);    try std.testing.expectEqualStrings("- one\r\n  - two", draft.body);} test "publish argv maps pull request workflow" {    var argv = try buildPublishArgv(std.testing.allocator, .pull_request, "A title", "Body text");    defer argv.deinit(std.testing.allocator);     try std.testing.expectEqual(@as(usize, 7), argv.items.len);    try std.testing.expectEqualStrings("gh", argv.items[0]);    try std.testing.expectEqualStrings("pr", argv.items[1]);    try std.testing.expectEqualStrings("create", argv.items[2]);    try std.testing.expectEqualStrings("--title", argv.items[3]);    try std.testing.expectEqualStrings("A title", argv.items[4]);    try std.testing.expectEqualStrings("--body", argv.items[5]);    try std.testing.expectEqualStrings("Body text", argv.items[6]);} test "publish argv maps issue workflow" {    var argv = try buildPublishArgv(std.testing.allocator, .issue, "A title", "Body text");    defer argv.deinit(std.testing.allocator);     try std.testing.expectEqual(@as(usize, 7), argv.items.len);    try std.testing.expectEqualStrings("gh", argv.items[0]);    try std.testing.expectEqualStrings("issue", argv.items[1]);    try std.testing.expectEqualStrings("create", argv.items[2]);    try std.testing.expectEqualStrings("--title", argv.items[3]);    try std.testing.expectEqualStrings("A title", argv.items[4]);    try std.testing.expectEqualStrings("--body", argv.items[5]);    try std.testing.expectEqualStrings("Body text", argv.items[6]);} test "publish maps missing gh executable to handled failure" {    const result = try publishResultFromRunError(std.testing.allocator, error.FileNotFound);    defer result.deinit(std.testing.allocator);     try std.testing.expect(!result.ok);    try std.testing.expectEqualStrings("gh CLI not found in PATH", result.text);} test "publish returns trimmed stderr for non-zero exit" {    const stdout = try std.testing.allocator.dupe(u8, "ignored");    const stderr = try std.testing.allocator.dupe(u8, " \nfailed to create\n ");     const result = try publishResultFromProcess(std.testing.allocator, .{        .term = .{ .exited = 1 },        .stdout = stdout,        .stderr = stderr,    });    defer result.deinit(std.testing.allocator);     try std.testing.expect(!result.ok);    try std.testing.expectEqualStrings("failed to create", result.text);} test "publish returns fallback for non-zero exit without stderr" {    const stdout = try std.testing.allocator.dupe(u8, "ignored");    const stderr = try std.testing.allocator.dupe(u8, " \n\t ");     const result = try publishResultFromProcess(std.testing.allocator, .{        .term = .{ .exited = 1 },        .stdout = stdout,        .stderr = stderr,    });    defer result.deinit(std.testing.allocator);     try std.testing.expect(!result.ok);    try std.testing.expectEqualStrings("gh command failed", result.text);} test "publish returns success fallback for empty stdout" {    const stdout = try std.testing.allocator.dupe(u8, " \n\t ");    const stderr = try std.testing.allocator.dupe(u8, "");     const result = try publishResultFromProcess(std.testing.allocator, .{        .term = .{ .exited = 0 },        .stdout = stdout,        .stderr = stderr,    });    defer result.deinit(std.testing.allocator);     try std.testing.expect(result.ok);    try std.testing.expectEqualStrings("created successfully", result.text);} test "publish trims padded stdout" {    const stdout = try std.testing.allocator.dupe(u8, " \nhttps://github.com/vercel-labs/fx/pull/1\n ");    const stderr = try std.testing.allocator.dupe(u8, "");     const result = try publishResultFromProcess(std.testing.allocator, .{        .term = .{ .exited = 0 },        .stdout = stdout,        .stderr = stderr,    });    defer result.deinit(std.testing.allocator);     try std.testing.expect(result.ok);    try std.testing.expectEqualStrings("https://github.com/vercel-labs/fx/pull/1", result.text);} test "publish transfers already-trimmed stdout ownership" {    const stdout = try std.testing.allocator.dupe(u8, "https://github.com/vercel-labs/fx/issues/2");    const stderr = try std.testing.allocator.dupe(u8, "");     const result = try publishResultFromProcess(std.testing.allocator, .{        .term = .{ .exited = 0 },        .stdout = stdout,        .stderr = stderr,    });    defer result.deinit(std.testing.allocator);     try std.testing.expect(result.ok);    try std.testing.expect(result.text.ptr == stdout.ptr);    try std.testing.expectEqualStrings("https://github.com/vercel-labs/fx/issues/2", result.text);}