╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─────────────╮ │src/ui/Repo/Issues.zig│ ╰──────────────────────╯ ╭────────────────────────────────────────────────────────────────╮ │2528 const label: []const u8 = switch (data.view) {│ │2529 .edit => "edit", │ │2530 .new_comment => "reply", │ │2531 + .edit_comment => "edit", │ │2532 .resolve => "resolve", │ │2533 else => "new", │ │2534 }; │ ╰────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │108 .comment => handleComment(io, request, allocator, base, host), │ │109 .open => handleIssueStatus(io, request, allocator, base, host, .open), │ │110 .close => handleIssueStatus(io, request, allocator, base, host, .closed),│ │111 - .edit => handleIssueEdit(io, request, allocator, base, host), │ │111 + .edit => handleEdit(io, request, allocator, base, host), │ │112 .resolve => handleIssueResolve(io, request, allocator, base, host), │ │113 }; │ │114 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │125 .comment => handleComment(io, request, allocator, base, host), │ │126 .open => handleIssueStatus(io, request, allocator, base, host, .open), │ │127 .close => handleIssueStatus(io, request, allocator, base, host, .closed),│ │128 - .edit => handleIssueEdit(io, request, allocator, base, host), │ │128 + .edit => handleEdit(io, request, allocator, base, host), │ │129 .resolve => handleIssueResolve(io, request, allocator, base, host), │ │130 }; │ │131 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────╮ │514 const CommentBaseParts = struct { │ │515 repo_base: []const u8, │ │516 thread_id: [evt.event_id_size]u8, │ │517 - parent_id: [evt.event_id_size]u8, │ │517 + comment_id: ?[evt.event_id_size]u8, │ │518 }; │ │519 │ │520 fn commentBaseParts(base: []const u8) ?CommentBaseParts {│ ╰──────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │527 │ │528 var thread_id: [evt.event_id_size]u8 = undefined; │ │529 _ = std.fmt.hexToBytes(&thread_id, tail[0..id_len]) catch return null; │ │530 - var parent_id = thread_id; │ │530 + var comment_id: ?[evt.event_id_size]u8 = null; │ │531 if (tail.len != id_len) { │ │532 if (tail.len != id_len + comment_infix.len + id_len) return null; │ │533 if (!std.mem.eql(u8, tail[id_len .. id_len + comment_infix.len], comment_infix)) return null;│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │534 - _ = std.fmt.hexToBytes(&parent_id, tail[id_len + comment_infix.len ..]) catch return null;│ │534 + var id: [evt.event_id_size]u8 = undefined; │ │535 + _ = std.fmt.hexToBytes(&id, tail[id_len + comment_infix.len ..]) catch return null; │ │536 + comment_id = id; │ │537 } │ │536 - return .{ .repo_base = base[0..issue_at], .thread_id = thread_id, .parent_id = parent_id }; │ │538 + return .{ .repo_base = base[0..issue_at], .thread_id = thread_id, .comment_id = comment_id }; │ │539 } │ │540 │ │541 // create a reply from an issue or comment /new form, then redirect to its │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────╮ │580 } │ │581 │ │582 const thread_id_hex = std.fmt.bytesToHex(parts.thread_id, .lower); │ │581 - const parent_id_hex = std.fmt.bytesToHex(parts.parent_id, .lower); │ │583 + const parent_id_hex = std.fmt.bytesToHex(parts.comment_id orelse parts.thread_id, .lower);│ │584 │ │585 var event_id_hex: [evt.event_id_size * 2]u8 = undefined; │ │586 switch (host) { │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │639 }); │ │640 } │ │641 │ │642 +// re-emit the comment `parts` names with a new body, in the repo the host │ │643 +// resolves the base to. │ │644 +fn updateComment( │ │645 + io: std.Io, │ │646 + allocator: std.mem.Allocator, │ │647 + host: Host, │ │648 + parts: CommentBaseParts, │ │649 + body: []const u8, │ │650 + author_email: []const u8, │ │651 +) !void { │ │652 + const comment_id = parts.comment_id orelse return error.NotFound; │ │653 + const thread_id = std.fmt.bytesToHex(parts.thread_id, .lower); │ │654 + switch (host) { │ │655 + .remote => |remote| { │ │656 + const repo_prefix = "/repo/"; │ │657 + if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return error.NotFound; │ │658 + const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); │ │659 + defer allocator.free(repos_dir); │ │660 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { │ │661 + .ok => |p| p, │ │662 + .invalid, .not_found => return error.NotFound, │ │663 + }; │ │664 + defer allocator.free(repo_path); │ │665 + │ │666 + var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); │ │667 + defer repo.deinit(io, allocator); │ │668 + try evt.Comment.update(.xit, .{}, io, allocator, &repo, &thread_id, &comment_id, body, author_email); │ │669 + }, │ │670 + .local => |src| { │ │671 + if (parts.repo_base.len != 0) return error.NotFound; │ │672 + switch (src.repo_kind) { │ │673 + inline else => |repo_kind| { │ │674 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); │ │675 + defer any_repo.deinit(io, allocator); │ │676 + switch (any_repo) { │ │677 + inline else => |*repo| try evt.Comment.update(repo_kind, repo.self_repo_opts, io, allocator, repo, &thread_id, &comment_id, body, author_email),│ │678 + } │ │679 + }, │ │680 + } │ │681 + }, │ │682 + } │ │683 +} │ │684 + │ │685 +// edit actions share a suffix; the base identifies whether the form names an │ │686 +// issue or comment. │ │687 +fn handleEdit( │ │688 + io: std.Io, │ │689 + request: *std.http.Server.Request, │ │690 + allocator: std.mem.Allocator, │ │691 + base: []const u8, │ │692 + host: Host, │ │693 +) !void { │ │694 + if (commentBaseParts(base)) |parts| { │ │695 + if (parts.comment_id != null) return handleCommentEdit(io, request, allocator, base, host, parts); │ │696 + } │ │697 + return handleIssueEdit(io, request, allocator, base, host); │ │698 +} │ │699 + │ │700 +// replace the body of the comment the url names, then redirect to its │ │701 +// permalink. │ │702 +fn handleCommentEdit( │ │703 + io: std.Io, │ │704 + request: *std.http.Server.Request, │ │705 + allocator: std.mem.Allocator, │ │706 + base: []const u8, │ │707 + host: Host, │ │708 + parts: CommentBaseParts, │ │709 +) !void { │ │710 + var author_arena = std.heap.ArenaAllocator.init(allocator); │ │711 + defer author_arena.deinit(); │ │712 + const author_email = (try eventAuthorEmail(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); │ │713 + │ │714 + var body_buf: [256]u8 = undefined; │ │715 + const reader = request.readerExpectNone(&body_buf); │ │716 + const posted = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{}; │ │717 + defer allocator.free(posted); │ │718 + const body_crlf = (try parseFormField(allocator, posted, "body")) orelse try allocator.dupe(u8, ""); │ │719 + defer allocator.free(body_crlf); │ │720 + const body = try std.mem.replaceOwned(u8, allocator, body_crlf, "\r\n", "\n"); │ │721 + defer allocator.free(body); │ │722 + │ │723 + if (!evt.Comment.fieldsValid(body)) { │ │724 + const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); │ │725 + defer allocator.free(form_location); │ │726 + try request.respond("", .{ │ │727 + .status = .see_other, │ │728 + .extra_headers = &.{.{ .name = "location", .value = form_location }}, │ │729 + }); │ │730 + return; │ │731 + } │ │732 + │ │733 + const not_found = "comment not found"; │ │734 + updateComment(io, allocator, host, parts, body, author_email) catch |err| switch (err) { │ │735 + error.NotFound => { │ │736 + try request.respond(not_found, .{ │ │737 + .status = .not_found, │ │738 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │739 + }); │ │740 + return; │ │741 + }, │ │742 + else => |e| return e, │ │743 + }; │ │744 + │ │745 + try request.respond("", .{ │ │746 + .status = .see_other, │ │747 + .extra_headers = &.{.{ .name = "location", .value = base }}, │ │748 + }); │ │749 +} │ │750 + │ │751 // split an issue url ("/repo/<owner>/<name>/issue:<id>", identity elided in │ │752 // local mode) into what precedes "/issue:" and the decoded id, null when it │ │753 // isn't one. the repo base is validated per host in updateIssue. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯