╭ Issue.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const evt = @import("../event.zig"); 3 const xit = @import("xit"); 4 const rp = xit.repo; 5 const hash = xit.hash; 6 7 title: []const u8, 8 description: []const u8, 9 tags: []const u8, // space-separated 10 status: Status = .open, 11 12 // what the db stores: the event's data plus the commit-derived fields 13 pub const Record = struct { 14 event: Self, 15 author_email: ?[]const u8 = null, 16 created_ts: u64 = 0, // the commit timestamp of the event that first created this issue 17 }; 18 19 const Self = @This(); 20 21 pub const Status = enum { 22 open, 23 closed, 24 25 const longest_len = blk: { 26 var len: usize = 0; 27 for (@typeInfo(Status).@"enum".fields) |field| len = @max(len, field.name.len); 28 break :blk len; 29 }; 30 }; 31 32 pub const tag_max_len = 64; 33 34 // the moment keys `evt.merge` reads and writes for this kind 35 pub const record_map_key = "event-id->issue"; 36 pub const id_set_key = "issue-id-set"; 37 pub const conflicts_key = "conflicted-issue-id->conflict"; 38 pub const id_to_field_to_oid_key = "issue-id->field->oid"; 39 40 // a "tag status" key names a tag's per-status issue set (tags can't contain 41 // spaces, so the pair is unambiguous) 42 pub const TagStatusKey = [tag_max_len + 1 + Status.longest_len]u8; 43 44 pub fn tagStatusKey(buffer: *TagStatusKey, tag: []const u8, status: Status) ![]const u8 { 45 return std.fmt.bufPrint(buffer, "{s} {s}", .{ tag, @tagName(status) }); 46 } 47 48 pub fn tagIterator(tags: []const u8) std.mem.SplitIterator(u8, .scalar) { 49 return std.mem.splitScalar(u8, tags, ' '); 50 } 51 52 // a title is required, and a too-long tag would fail consumption after the 53 // event is already committed 54 pub fn fieldsValid(title: []const u8, tags: []const u8) bool { 55 if (title.len == 0) return false; 56 var tag_iter = tagIterator(tags); 57 while (tag_iter.next()) |tag| { 58 if (tag.len > tag_max_len) return false; 59 } 60 return true; 61 } 62 63 pub fn consume( 64 comptime DB: type, 65 comptime hash_kind: hash.HashKind, 66 haxy_moment: DB.HashMap(.read_write), 67 event_id: *const [evt.event_id_size]u8, 68 record_maybe: ?Record, 69 arena: *std.heap.ArenaAllocator, 70 // the commit this event came from, recorded against every field it 71 // changes. null from `merge`, which sets oids and conflicts itself. 72 event_oid: ?[]const u8, 73 ) !void { 74 const issue_key = hash.hashInt(hash_kind, event_id); 75 76 const event_id_to_issue_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "event-id->issue")); 77 const event_id_to_issue = try DB.HashMap(.read_write).init(event_id_to_issue_cursor); 78 79 // the per-status sets the issues views list, ordered by creation time, 80 // keyed by status name 81 const status_to_issues_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "status->issue-id-set")); 82 const status_to_issues = try DB.SortedMap(.read_write).init(status_to_issues_cursor); 83 84 // the per-tag, per-status sets, keyed "tag,status" 85 const tag_to_issues_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, "tag+status->issue-id-set")); 86 const tag_to_issues = try DB.SortedMap(.read_write).init(tag_to_issues_cursor); 87 88 const conflicts_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, conflicts_key)); 89 const conflicts = try DB.SortedMap(.read_write).init(conflicts_cursor); 90 91 const issue_id_to_field_to_oid_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, id_to_field_to_oid_key)); 92 const issue_id_to_field_to_oid = try DB.HashMap(.read_write).init(issue_id_to_field_to_oid_cursor); 93 94 if (record_maybe) |record| { 95 var record_to_write = record; 96 97 var existing_record_maybe: ?Record = null; 98 const existing_cursor_maybe = try event_id_to_issue.getCursor(issue_key); 99 if (existing_cursor_maybe) |existing_cursor| { 100 // updates preserve the original creation timestamp and author 101 const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); 102 const existing_record = try evt.read(Record, DB, hash_kind, arena, existing_issue); 103 existing_record_maybe = existing_record; 104 record_to_write.created_ts = existing_record.created_ts; 105 record_to_write.author_email = existing_record.author_email; 106 107 // drop the old status's and tags' entries; the current ones are re-added below 108 const order_key = evt.orderKeyDesc(existing_record.created_ts, event_id); 109 const status_set = try statusSet(DB, status_to_issues, existing_record.event.status); 110 _ = try status_set.remove(&order_key); 111 try removeFromTagSets(DB, tag_to_issues, existing_record.event.tags, existing_record.event.status, &order_key); 112 113 // any event settles the conflict, since resolving in the ui may 114 // keep our own values and so change nothing to detect 115 if (event_oid != null) _ = try conflicts.remove(&order_key); 116 } 117 118 try evt.writeOid(Self, DB, hash_kind, issue_id_to_field_to_oid, issue_key, if (existing_record_maybe) |existing| existing.event else null, record.event, event_oid); 119 120 const issue_cursor = try event_id_to_issue.putCursor(issue_key); 121 const issue = try DB.HashMap(.read_write).init(issue_cursor); 122 try evt.upsert(Record, DB, hash_kind, issue, record_to_write); 123 124 const order_key = evt.orderKeyDesc(record_to_write.created_ts, event_id); 125 126 // first time we've seen this issue: add it to the set that enumerates 127 // every issue regardless of status 128 if (existing_cursor_maybe == null) { 129 const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, id_set_key)); 130 const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); 131 try issue_id_set.put(&order_key); 132 } 133 134 const status_set = try statusSet(DB, status_to_issues, record.event.status); 135 try status_set.put(&order_key); 136 137 var tag_iter = tagIterator(record.event.tags); 138 while (tag_iter.next()) |tag| { 139 if (tag.len == 0) continue; 140 if (tag.len > tag_max_len) return error.TagTooLong; 141 var key_buffer: TagStatusKey = undefined; 142 const tag_set_cursor = try tag_to_issues.putCursor(try tagStatusKey(&key_buffer, tag, record.event.status)); 143 const tag_set = try DB.SortedSet(.read_write).init(tag_set_cursor); 144 try tag_set.put(&order_key); 145 } 146 } else { 147 // drop it from the ordered sets using its recorded creation timestamp and status 148 if (try event_id_to_issue.getCursor(issue_key)) |existing_cursor| { 149 const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); 150 const existing_record = try evt.read(Record, DB, hash_kind, arena, existing_issue); 151 const order_key = evt.orderKeyDesc(existing_record.created_ts, event_id); 152 153 const status_set = try statusSet(DB, status_to_issues, existing_record.event.status); 154 _ = try status_set.remove(&order_key); 155 156 try removeFromTagSets(DB, tag_to_issues, existing_record.event.tags, existing_record.event.status, &order_key); 157 158 const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, id_set_key)); 159 const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); 160 _ = try issue_id_set.remove(&order_key); 161 162 _ = try conflicts.remove(&order_key); 163 _ = try issue_id_to_field_to_oid.remove(issue_key); 164 } 165 if (!try event_id_to_issue.remove(issue_key)) return error.EventNotFound; 166 } 167 } 168 169 // `status`'s sorted set within `statuses`, keyed by status name 170 fn statusSet( 171 comptime DB: type, 172 statuses: DB.SortedMap(.read_write), 173 status: Status, 174 ) !DB.SortedSet(.read_write) { 175 const cursor = try statuses.putCursor(@tagName(status)); 176 return DB.SortedSet(.read_write).init(cursor); 177 } 178 179 // what an update replaces on an issue: its status, or its content 180 pub const Update = union(enum) { 181 status: Status, 182 fields: struct { title: []const u8, tags: []const u8, description: []const u8 }, 183 }; 184 185 // re-emit the issue `id_bytes` names with `change` applied, preserving the 186 // rest of its current state. error.NotFound when the id names no issue. 187 // `repo` must be writable. 188 pub fn update( 189 comptime repo_kind: rp.RepoKind, 190 comptime repo_opts: rp.RepoOpts(repo_kind), 191 io: std.Io, 192 allocator: std.mem.Allocator, 193 repo: *rp.Repo(repo_kind, repo_opts), 194 id_bytes: *const [evt.event_id_size]u8, 195 change: Update, 196 author_email: []const u8, 197 ) !void { 198 var arena = std.heap.ArenaAllocator.init(allocator); 199 defer arena.deinit(); 200 201 const issue = (try readById(repo_kind, repo_opts, io, allocator, &arena, repo, id_bytes)) orelse return error.NotFound; 202 203 var updated = issue.event; 204 switch (change) { 205 .status => |status| { 206 if (updated.status == status) return; 207 updated.status = status; 208 }, 209 .fields => |fields| { 210 updated.title = fields.title; 211 updated.tags = fields.tags; 212 updated.description = fields.description; 213 }, 214 } 215 216 try evt.consume(repo_kind, repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{.{ 217 .id = std.fmt.bytesToHex(id_bytes.*, .lower), 218 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 219 .author_email = author_email, 220 .event = .{ .issue = updated }, 221 }}); 222 } 223 224 // the issue `id_bytes` names in the repo's consumed event db (the repo's own 225 // db for xit; the side db next to a git repo), or null 226 fn readById( 227 comptime repo_kind: rp.RepoKind, 228 comptime repo_opts: rp.RepoOpts(repo_kind), 229 io: std.Io, 230 allocator: std.mem.Allocator, 231 arena: *std.heap.ArenaAllocator, 232 repo: *rp.Repo(repo_kind, repo_opts), 233 id_bytes: *const [evt.event_id_size]u8, 234 ) !?Record { 235 const DB = evt.EventDB(repo_opts.hash); 236 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; 237 defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); 238 const moment = (if (event_db_maybe) |*event_db| 239 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 240 else if (repo_kind == .git) 241 return null 242 else 243 evt.currentMoment(repo_opts, repo)) catch return null; 244 const event_id_to_issue_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue"))) orelse return null; 245 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 246 const issue_cursor = (try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, id_bytes))) orelse return null; 247 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 248 return try evt.read(Record, DB, repo_opts.hash, arena, issue_map); 249 } 250 251 // remove an issue's order key from its tags' `status` sets, pruning entries 252 // whose set becomes empty 253 fn removeFromTagSets( 254 comptime DB: type, 255 tag_to_issues: DB.SortedMap(.read_write), 256 tags: []const u8, 257 status: Status, 258 order_key: []const u8, 259 ) !void { 260 var tag_iter = tagIterator(tags); 261 while (tag_iter.next()) |tag| { 262 if (tag.len == 0) continue; 263 var key_buffer: TagStatusKey = undefined; 264 const key = try tagStatusKey(&key_buffer, tag, status); 265 const tag_set_cursor = try tag_to_issues.putCursor(key); 266 const tag_set = try DB.SortedSet(.read_write).init(tag_set_cursor); 267 _ = try tag_set.remove(order_key); 268 if (0 == try tag_set.count()) _ = try tag_to_issues.remove(key); 269 } 270 } 271