// for use inside of github, build with // zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSmall announceybot // then copy to ./announceybot.exe const std = @import("std"); const README_PATH = "README.md"; const README_MAX_SIZE = 25 * 1024; const README_UPDATE_TEMPLATE = @embedFile("./announceybot/release-dep-update-template.md"); const RELEASE_NOTES_TEMPLATE = @embedFile("./announceybot/release-note-template.md"); const RELEASE_ANNOUNCEMENT_TEMPLATE = @embedFile("./announceybot/release-announcement-template.md"); const REPLACE_BEGIN_MARKER = ""; const REPLACE_END_MARKER = ""; fn usage() void { const message = \\ \\Usage: announceybot [] \\ \\Commands: \\ help : prints this help, no tag required \\ \\ announce : announce release in #announce discord channel \\ expects the discord webhook URL in the env var \\ named `WEBHOOK_URL` \\ \\ release-notes: print release notes for the given git tag \\ \\ update-readme: modify the README.md to the latest build.zig.zon \\ instructions ; std.debug.print("{s}", .{message}); std.process.exit(1); } var general_purpose_allocator = std.heap.GeneralPurposeAllocator(.{}){}; pub fn main() !void { const gpa = general_purpose_allocator.allocator(); defer _ = general_purpose_allocator.deinit(); var arena_instance = std.heap.ArenaAllocator.init(gpa); defer arena_instance.deinit(); const arena = arena_instance.allocator(); const args = try std.process.argsAlloc(arena); if (args.len < 3) { // includes help command :-) return usage(); } const command = args[1]; const tagname = args[2]; if (std.mem.eql(u8, command, "help")) { return usage(); } if (std.mem.eql(u8, command, "announce")) { return command_announce(gpa, tagname); } if (std.mem.eql(u8, command, "release-notes")) { return command_releasenotes(gpa, tagname); } if (std.mem.eql(u8, command, "update-readme")) { return command_update_readme(gpa, tagname); } // undocumented commands if (std.mem.eql(u8, command, "tag-annotation")) { const annotation = try get_tag_annotation(gpa, tagname); defer gpa.free(annotation); std.debug.print("{s}\n", .{annotation}); return; } // default: command not found return usage(); } /// returns the tag's annotation you own and must free fn get_tag_annotation(allocator: std.mem.Allocator, tagname: []const u8) ![]const u8 { const args = [_][]const u8{ "git", "tag", "-l", "--format=%(contents)", tagname, }; const result = try std.process.Child.run(.{ .allocator = allocator, .argv = &args, }); const return_string = switch (result.term) { .Exited => |code| if (code == 0) result.stdout else result.stderr, else => result.stderr, }; defer { allocator.free(result.stdout); allocator.free(result.stderr); } return try allocator.dupe(u8, return_string); } const RenderParams = struct { tag: ?[]const u8 = null, hash: ?[]const u8 = null, annotation: ?[]const u8 = null, }; fn renderTemplate(allocator: std.mem.Allocator, template: []const u8, substitutes: RenderParams) ![]const u8 { const the_tag = substitutes.tag orelse ""; const the_anno = substitutes.annotation orelse ""; const s1 = try std.mem.replaceOwned(u8, allocator, template, "{tag}", the_tag); defer allocator.free(s1); return try std.mem.replaceOwned(u8, allocator, s1, "{annotation}", the_anno); } fn sendToDiscordPart(allocator: std.mem.Allocator, url: []const u8, message_json: []const u8) !void { // client var http_client: std.http.Client = .{ .allocator = allocator }; const response = try http_client.fetch(.{ .location = .{ .url = url }, .payload = message_json, .method = .POST, .headers = .{ .content_type = .{ .override = "application/json" } }, .keep_alive = false, }); if (response.status.class() != .success) { std.debug.print("Discord: {?s}", .{response.status.phrase()}); return error.DiscordPostError; } } fn sendToDiscord(allocator: std.mem.Allocator, url: []const u8, message: []const u8) !void { // json payload // max size: 100kB const buf: []u8 = try allocator.alloc(u8, 100 * 1024); defer allocator.free(buf); var w: std.io.Writer = .fixed(buf); try std.json.Stringify.value(.{ .content = message }, .{}, &w); const string = w.buffered(); // We need to split shit into max 2000 characters if (string.len < 1999) { try sendToDiscordPart(allocator, url, string); return; } // we can re-use the buf now // we need to split // we split the string at 1500 chars max. This should leave plenty // of room for the encoded message being < 2000 chars const SPLIT_THRESHOLD = 1500; const Desc = struct { from: usize, to: usize, }; var chunks = std.ArrayList(Desc).empty; defer chunks.deinit(allocator); var i: usize = 0; var chunk_i: usize = 0; var last_newline_index: usize = 0; var last_from: usize = 0; var in_code_block: bool = false; std.debug.print("Needing to split message of size {d}.\n", .{message.len}); while (true) { if (chunk_i > SPLIT_THRESHOLD) { // start a new chunk // we assume, there was a newline in 1990 bytes // try chunks.append(message[last_newline_index..i]); try chunks.append(allocator, .{ .from = last_from, .to = last_newline_index }); chunk_i = 0; last_from = last_newline_index + 1; i = last_from; if (i >= message.len) { break; } continue; } if (message[i] == '\n') { // we won't use any newline, only ones outside of code blocks var next_line_is_code_block_marker: bool = false; if (i + 3 < message.len) { if (std.mem.eql(u8, message[i + 1 .. i + 4], "```")) { next_line_is_code_block_marker = true; in_code_block = !in_code_block; if (in_code_block) { // we're going to be in a code block // so we can keep the newline that's the last // newline before the code block last_newline_index = i; i += 1; chunk_i += 1; continue; } else { // next line is a code block marker that is // ending the current one, so we can take this // one as the first one that's ok again last_newline_index = i; i += 1; chunk_i += 1; continue; } } } if (in_code_block and next_line_is_code_block_marker) { in_code_block = false; i += 1; chunk_i += 1; continue; } if (in_code_block) { i += 1; chunk_i += 1; continue; } // we remember everything outside a code block last_newline_index = i; } i += 1; chunk_i += 1; if (i >= message.len) { // push last part // try chunks.append(message[last_newline_index..i]); try chunks.append(allocator, .{ .from = last_from, .to = i }); break; } } // std.debug.print("Message split into {} parts:\n", .{chunks.items.len}); // // var it: usize = 0; // while (it < chunks.items.len) { // std.debug.print("PART {}: {any}\n", .{ it, chunks.items[it] }); // it += 1; // } // // it = 0; // while (it < chunks.items.len) { // const desc = chunks.items[it]; // std.debug.print("PART {}: {s}\n", .{ it, message[desc.from..desc.to] }); // it += 1; // } var it: usize = 0; while (it < chunks.items.len) { const desc = chunks.items[it]; const part = message[desc.from..desc.to]; var ww: std.io.Writer = .fixed(buf); try std.json.Stringify.value(.{ .content = part }, .{}, &ww); const part_string = ww.buffered(); std.debug.print("SENDING PART {d} / {d}: ... ", .{ it, chunks.items.len }); try sendToDiscordPart(allocator, url, part_string); std.debug.print("done!\n", .{}); it += 1; } } fn command_announce(allocator: std.mem.Allocator, tag: []const u8) !void { const annotation = try get_tag_annotation(allocator, tag); defer allocator.free(annotation); const announcement = try renderTemplate(allocator, RELEASE_ANNOUNCEMENT_TEMPLATE, .{ .tag = tag, .annotation = annotation, }); // std.debug.print("{s}\n", .{announcement}); defer allocator.free(announcement); const url = try std.process.getEnvVarOwned(allocator, "WEBHOOK_URL"); defer allocator.free(url); sendToDiscord(allocator, url, announcement) catch |err| { std.debug.print("HTTP ERROR: {any}\n", .{err}); std.process.exit(1); }; } fn command_releasenotes(allocator: std.mem.Allocator, tag: []const u8) !void { const annotation = try get_tag_annotation(allocator, tag); defer allocator.free(annotation); const release_notes = try renderTemplate(allocator, RELEASE_NOTES_TEMPLATE, .{ .tag = tag, .annotation = annotation, }); defer allocator.free(release_notes); var stdout_buffer: [1024]u8 = undefined; var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); const stdout = &stdout_writer.interface; try stdout.writeAll(release_notes); try stdout.flush(); } fn command_update_readme(allocator: std.mem.Allocator, tag: []const u8) !void { const annotation = try get_tag_annotation(allocator, tag); defer allocator.free(annotation); const update_part = try renderTemplate(allocator, README_UPDATE_TEMPLATE, .{ .tag = tag, .annotation = annotation, }); defer allocator.free(update_part); // read the readme const readme = try std.fs.cwd().readFileAlloc(allocator, README_PATH, README_MAX_SIZE); defer allocator.free(readme); var output_file = try std.fs.cwd().createFile(README_PATH, .{}); defer output_file.close(); var output_buffer: [2048]u8 = undefined; var output_writer = output_file.writer(&output_buffer); const writer = &output_writer.interface; // iterate over lines var in_replace_block: bool = false; var line_it = std.mem.splitScalar(u8, readme, '\n'); while (line_it.next()) |line| { if (in_replace_block) { if (std.mem.startsWith(u8, line, REPLACE_END_MARKER)) { in_replace_block = false; } continue; } if (std.mem.startsWith(u8, line, REPLACE_BEGIN_MARKER)) { _ = try writer.writeAll(REPLACE_BEGIN_MARKER); _ = try writer.writeByte('\n'); _ = try writer.writeAll(update_part); _ = try writer.writeAll(REPLACE_END_MARKER); _ = try writer.writeByte('\n'); in_replace_block = true; continue; } // we need to put the \n back in. // TODO: change this by using some "search" iterator that just // returns indices etc const output_line = try std.fmt.allocPrint(allocator, "{s}\n", .{line}); defer allocator.free(output_line); _ = try writer.writeAll(output_line); } try writer.flush(); // don't forget to flush! }