╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │480 if (!std.mem.endsWith(u8, head, issues_infix)) return null; │ │443 - const repo_base = head[0 .. head.len - issues_infix.len]; │ │444 - if (!std.mem.startsWith(u8, repo_base, repo_prefix)) return null; │ │445 - return .{ .repo_base = repo_base, .id_bytes = id_bytes }; │ │481 + return .{ .repo_base = head[0 .. head.len - issues_infix.len], .id_bytes = id_bytes }; │ │482 +} │ │483 + │ │484 +// what an update route replaces on an issue: its status, or its content. │ │485 +const IssueUpdate = union(enum) { │ │486 + status: evt.Issue.Status, │ │487 + fields: struct { title: []const u8, tags: []const u8, description: []const u8 }, │ │488 +}; │ │489 + │ │490 +// re-emit the issue `parts` names with `update` applied, in the repo the │ │491 +// host resolves the base to. error.NotFound when the base names no repo or │ │492 +// the id no issue. │ │493 +fn updateIssue( │ │494 + io: std.Io, │ │495 + allocator: std.mem.Allocator, │ │496 + host: Host, │ │497 + parts: IssueBaseParts, │ │498 + update: IssueUpdate, │ │499 +) !void { │ │500 + switch (host) { │ │501 + .remote => |remote| { │ │502 + const repo_prefix = "/repo/"; │ │503 + if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return error.NotFound; │ │504 + const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); │ │505 + defer allocator.free(repos_dir); │ │506 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { │ │507 + .ok => |p| p, │ │508 + .invalid, .not_found => return error.NotFound, │ │509 + }; │ │510 + defer allocator.free(repo_path); │ │511 + │ │512 + var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); │ │513 + defer repo.deinit(io, allocator); │ │514 + try updateIssueInRepo(.xit, .{}, io, allocator, &repo, &parts.id_bytes, update); │ │515 + }, │ │516 + .local => |src| { │ │517 + // the local forms post to "/issues/<id>/...", so the repo base │ │518 + // is empty │ │519 + if (parts.repo_base.len != 0) return error.NotFound; │ │520 + switch (src.repo_kind) { │ │521 + inline else => |repo_kind| { │ │522 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, .{ .path = src.path }); │ │523 + defer any_repo.deinit(io, allocator); │ │524 + switch (any_repo) { │ │525 + inline else => |*repo| try updateIssueInRepo(repo_kind, repo.self_repo_opts, io, allocator, repo, &parts.id_bytes, update), │ │526 + } │ │527 + }, │ │528 + } │ │529 + }, │ │530 + } │ │531 } │ │532 │ │448 -// the issue `id_bytes` names in `repo`'s consumed event db, or null │ │449 -fn readIssue( │ │450 - repo: *rp.Repo(.xit, .{}), │ │533 +fn updateIssueInRepo( │ │534 + comptime repo_kind: rp.RepoKind, │ │535 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │536 + io: std.Io, │ │537 + allocator: std.mem.Allocator, │ │538 + repo: *rp.Repo(repo_kind, repo_opts), │ │539 + id_bytes: *const [evt.event_id_size]u8, │ │540 + update: IssueUpdate, │ │541 +) !void { │ │542 + var arena = std.heap.ArenaAllocator.init(allocator); │ │543 + defer arena.deinit(); │ │544 + │ │545 + const issue = (try readRepoIssue(repo_kind, repo_opts, io, allocator, &arena, repo, id_bytes)) orelse return error.NotFound; │ │546 + │ │547 + var updated = issue; │ │548 + switch (update) { │ │549 + .status => |status| { │ │550 + if (issue.status == status) return; │ │551 + updated.status = status; │ │552 + }, │ │553 + .fields => |fields| { │ │554 + updated.title = fields.title; │ │555 + updated.tags = fields.tags; │ │556 + updated.description = fields.description; │ │557 + }, │ │558 + } │ │559 + │ │560 + try evt.commitAndConsume(repo_kind, repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{.{ │ │561 + .id = std.fmt.bytesToHex(id_bytes.*, .lower), │ │562 + .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │563 + .event = .{ .issue = updated }, │ │564 + }}); │ │565 +} │ │566 + │ │567 +// the issue `id_bytes` names in the repo's consumed event db (the repo's own │ │568 +// db for xit; the side db next to a git repo), or null │ │569 +fn readRepoIssue( │ │570 + comptime repo_kind: rp.RepoKind, │ │571 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │572 + io: std.Io, │ │573 + allocator: std.mem.Allocator, │ │574 arena: *std.heap.ArenaAllocator, │ │575 + repo: *rp.Repo(repo_kind, repo_opts), │ │576 id_bytes: *const [evt.event_id_size]u8, │ │577 ) !?evt.Issue { │ │454 - const repo_opts: rp.RepoOpts(.xit) = .{}; │ │455 - const DB = rp.Repo(.xit, repo_opts).DB; │ │456 - const moment = evt.currentMoment(repo_opts, repo) catch return null; │ │578 + const DB = evt.EventDB(repo_opts.hash); │ │579 + var event_db_maybe: ?evt.LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try evt.LocalEventDB(repo_opts.hash).openReadOnly(io, allocator, repo.core.repo_dir) else null;│ │580 + defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); │ │581 + const moment = (if (event_db_maybe) |*event_db| │ │582 + evt.currentMomentFromDb(repo_opts.hash, event_db.db) │ │583 + else if (repo_kind == .git) │ │584 + return null │ │585 + else │ │586 + evt.currentMoment(repo_opts, repo)) catch return null; │ │587 const event_id_to_issue_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue"))) orelse return null; │ │588 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); │ │589 const issue_cursor = (try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, id_bytes))) orelse return null; │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │592 } │ │593 │ │594 // set the status of the issue the url names (base is │ │465 -// "/repo/<owner>/<name>/issues/<id>") by re-emitting its event, then │ │466 -// redirect back to it │ │595 +// "/repo/<owner>/<name>/issues/<id>", identity elided in local mode) by│ │596 +// re-emitting its event, then redirect back to it │ │597 fn handleIssueStatus( │ │598 io: std.Io, │ │599 request: *std.http.Server.Request, │ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────╮ │600 allocator: std.mem.Allocator, │ │601 base: []const u8, │ │472 - admin_repo_path: []const u8, │ │602 + host: Host, │ │603 status: evt.Issue.Status, │ │604 ) !void { │ │475 - const repo_prefix = "/repo/"; │ │476 - const id_len = evt.event_id_size * 2; │ │477 - │ │605 const not_found = "issue not found"; │ │606 const parts = issueBaseParts(base) orelse {│ │607 try request.respond(not_found, .{ │ ╰────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │611 return; │ │612 }; │ │613 │ │487 - const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(admin_repo_path) orelse ".", "repos" }); │ │488 - defer allocator.free(repos_dir); │ │489 - const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, admin_repo_path, parts.repo_base[repo_prefix.len..], false)) {│ │490 - .ok => |p| p, │ │491 - .invalid, .not_found => { │ │614 + updateIssue(io, allocator, host, parts, .{ .status = status }) catch |err| switch (err) { │ │615 + error.NotFound => { │ │616 try request.respond(not_found, .{ │ │617 .status = .not_found, │ │618 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │619 }); │ │620 return; │ │621 }, │ │498 - }; │ │499 - defer allocator.free(repo_path); │ │500 - │ │501 - const repo_opts: rp.RepoOpts(.xit) = .{}; │ │502 - var repo = try rp.Repo(.xit, repo_opts).open(io, allocator, .{ .path = repo_path }); │ │503 - defer repo.deinit(io, allocator); │ │504 - │ │505 - var arena = std.heap.ArenaAllocator.init(allocator); │ │506 - defer arena.deinit(); │ │507 - │ │508 - const issue = (try readIssue(&repo, &arena, &parts.id_bytes)) orelse { │ │509 - try request.respond(not_found, .{ │ │510 - .status = .not_found, │ │511 - .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │512 - }); │ │513 - return; │ │622 + else => |e| return e, │ │623 }; │ │515 - │ │516 - if (issue.status != status) { │ │517 - var updated = issue; │ │518 - updated.status = status; │ │519 - try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{.{│ │520 - .id = base[base.len - id_len ..][0..id_len].*, │ │521 - .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │522 - .event = .{ .issue = updated }, │ │523 - }}); │ │524 - } │ │624 │ │625 // like logout, this is a bodyless POST, so close the connection rather than │ │626 // letting the keep-alive path try to discard a body that isn't framed. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │632 } │ │633 │ │634 // replace the title/tags/description of the issue the url names (base is │ │536 -// "/repo/<owner>/<name>/issues/<id>", the form posts to "<base>/edit") by │ │537 -// re-emitting its event with its status preserved, then redirect back to it│ │635 +// "/repo/<owner>/<name>/issues/<id>", identity elided in local mode; the │ │636 +// form posts to "<base>/edit") by re-emitting its event with its status │ │637 +// preserved, then redirect back to it │ │638 fn handleIssueEdit( │ │639 io: std.Io, │ │640 request: *std.http.Server.Request, │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │641 allocator: std.mem.Allocator, │ │642 base: []const u8, │ │543 - admin_repo_path: []const u8, │ │643 + host: Host, │ │644 ) !void { │ │545 - const repo_prefix = "/repo/"; │ │546 - const id_len = evt.event_id_size * 2; │ │547 - │ │645 var body_buf: [256]u8 = undefined; │ │646 const reader = request.readerExpectNone(&body_buf); │ │647 const body = reader.allocRemaining(allocator, .limited(65536)) catch &[_]u8{};│ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │678 return; │ │679 }; │ │680 │ │584 - const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(admin_repo_path) orelse ".", "repos" }); │ │585 - defer allocator.free(repos_dir); │ │586 - const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, admin_repo_path, parts.repo_base[repo_prefix.len..], false)) {│ │587 - .ok => |p| p, │ │588 - .invalid, .not_found => { │ │681 + updateIssue(io, allocator, host, parts, .{ .fields = .{ │ │682 + .title = title, │ │683 + .tags = tags, │ │684 + .description = description, │ │685 + } }) catch |err| switch (err) { │ │686 + error.NotFound => { │ │687 try request.respond(not_found, .{ │ │688 .status = .not_found, │ │689 .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │690 }); │ │691 return; │ │692 }, │ │595 - }; │ │596 - defer allocator.free(repo_path); │ │597 - │ │598 - const repo_opts: rp.RepoOpts(.xit) = .{}; │ │599 - var repo = try rp.Repo(.xit, repo_opts).open(io, allocator, .{ .path = repo_path }); │ │600 - defer repo.deinit(io, allocator); │ │601 - │ │602 - var arena = std.heap.ArenaAllocator.init(allocator); │ │603 - defer arena.deinit(); │ │604 - │ │605 - const issue = (try readIssue(&repo, &arena, &parts.id_bytes)) orelse { │ │606 - try request.respond(not_found, .{ │ │607 - .status = .not_found, │ │608 - .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │609 - }); │ │610 - return; │ │693 + else => |e| return e, │ │694 }; │ │612 - │ │613 - var updated = issue; │ │614 - updated.title = title; │ │615 - updated.tags = tags; │ │616 - updated.description = description; │ │617 - try evt.commitAndConsume(.xit, repo_opts, io, allocator, &repo, evt.events_ref, &[_]evt.EventWithId{.{│ │618 - .id = base[base.len - id_len ..][0..id_len].*, │ │619 - .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), │ │620 - .event = .{ .issue = updated }, │ │621 - }}); │ │695 │ │696 try request.respond("", .{ │ │697 .status = .see_other, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯