╭ Issue.zig ────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const evt = @import("../event.zig"); 3 const diff3 = @import("../diff3.zig"); 4 const xit = @import("xit"); 5 const rp = xit.repo; 6 const hash = xit.hash; 7 8 title: []const u8, 9 description: []const u8, 10 tags: []const u8, // space-separated 11 status: Status = .open, 12 13 // what the db stores: the event's data plus the commit-derived fields 14 pub const Record = struct { 15 event: Self, 16 removed: bool = false, 17 author_email: ?[]const u8 = null, 18 created_order: u64 = 0, 19 updated_order: u64 = 0, 20 }; 21 22 const Self = @This(); 23 24 pub const Status = enum { 25 open, 26 closed, 27 28 const longest_len = blk: { 29 var len: usize = 0; 30 for (@typeInfo(Status).@"enum".fields) |field| len = @max(len, field.name.len); 31 break :blk len; 32 }; 33 }; 34 35 pub const tag_max_len = 64; 36 37 // the moment keys `evt.merge` reads and writes for this kind 38 pub const merge_policy: evt.MergePolicy = .field_conflicts; 39 pub const record_map_key = "event-id->issue"; 40 pub const all_id_set_key = "issue-id-set"; 41 pub const conflicts_key = "conflicted-issue-id->conflict"; 42 pub const id_to_field_to_oid_key = "issue-id->field->oid"; 43 pub const status_to_id_set_key = "status->issue-id-set"; 44 pub const tag_status_to_id_set_key = "tag+status->issue-id-set"; 45 46 // a "tag status" key names a tag's per-status issue set (tags can't contain 47 // spaces, so the pair is unambiguous) 48 pub const TagStatusKey = [tag_max_len + 1 + Status.longest_len]u8; 49 50 pub fn tagStatusKey(buffer: *TagStatusKey, tag: []const u8, status: Status) ![]const u8 { 51 return std.fmt.bufPrint(buffer, "{s} {s}", .{ tag, @tagName(status) }); 52 } 53 54 pub fn tagIterator(tags: []const u8) std.mem.SplitIterator(u8, .scalar) { 55 return std.mem.splitScalar(u8, tags, ' '); 56 } 57 58 // a title is required, and a too-long tag would go unindexed once consumed 59 pub fn fieldsValid(title: []const u8, tags: []const u8) bool { 60 if (!evt.titleValid(title)) return false; 61 var tag_iter = tagIterator(tags); 62 while (tag_iter.next()) |tag| { 63 if (tag.len > tag_max_len) return false; 64 } 65 return true; 66 } 67 68 pub fn consume( 69 comptime DB: type, 70 comptime hash_kind: hash.HashKind, 71 haxy_moment: DB.HashMap(.read_write), 72 event_id: *const [evt.event_id_size]u8, 73 record_maybe: ?Record, 74 arena: *std.heap.ArenaAllocator, 75 // the commit this event came from, recorded against every field it 76 // changes. null from `merge`, which sets oids and conflicts itself. 77 event_oid: ?[]const u8, 78 ) !void { 79 const issue_key = hash.hashInt(hash_kind, event_id); 80 81 const event_id_to_issue_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, record_map_key)); 82 const event_id_to_issue = try DB.HashMap(.read_write).init(event_id_to_issue_cursor); 83 84 // the per-status sets the issues views list, ordered by creation, 85 // keyed by status name 86 const status_to_issues_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, status_to_id_set_key)); 87 const status_to_issues = try DB.SortedMap(.read_write).init(status_to_issues_cursor); 88 89 // the per-tag, per-status sets, keyed "tag,status" 90 const tag_to_issues_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, tag_status_to_id_set_key)); 91 const tag_to_issues = try DB.SortedMap(.read_write).init(tag_to_issues_cursor); 92 93 const conflicts_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, conflicts_key)); 94 const conflicts = try DB.SortedMap(.read_write).init(conflicts_cursor); 95 96 const issue_id_to_field_to_oid_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, id_to_field_to_oid_key)); 97 const issue_id_to_field_to_oid = try DB.HashMap(.read_write).init(issue_id_to_field_to_oid_cursor); 98 99 var existing_record_maybe: ?Record = null; 100 const existing_cursor_maybe = try event_id_to_issue.getCursor(issue_key); 101 if (existing_cursor_maybe) |existing_cursor| { 102 const existing_issue = try DB.HashMap(.read_only).init(existing_cursor); 103 existing_record_maybe = try evt.read(Record, DB, hash_kind, arena, existing_issue); 104 } 105 106 var record_to_write = record_maybe orelse try evt.removedRecord(Record, DB, hash_kind, haxy_moment.readOnly(), existing_record_maybe); 107 108 if (existing_record_maybe) |existing_record| { 109 // updates preserve the original creation order and author 110 record_to_write.created_order = existing_record.created_order; 111 record_to_write.author_email = existing_record.author_email; 112 113 // drop the old status's and tags' entries; active values are re-added below 114 const order_key = evt.orderKeyDesc(existing_record.created_order, event_id); 115 if (!existing_record.removed) { 116 const status_set = try statusSet(DB, status_to_issues, existing_record.event.status); 117 _ = try status_set.remove(&order_key); 118 try removeFromTagSets(DB, tag_to_issues, existing_record.event.tags, existing_record.event.status, &order_key); 119 } 120 121 // any event settles the conflict, since resolving in the ui may 122 // keep our own values and so change nothing to detect 123 if (event_oid != null or record_to_write.removed) _ = try conflicts.remove(&order_key); 124 } 125 126 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_to_write.event, event_oid); 127 128 const issue_cursor = try event_id_to_issue.putCursor(issue_key); 129 const issue = try DB.HashMap(.read_write).init(issue_cursor); 130 try evt.upsert(Record, DB, hash_kind, issue, record_to_write); 131 try evt.indexEvent(DB, hash_kind, haxy_moment, event_id, .issue, existing_record_maybe, record_to_write); 132 133 const order_key = evt.orderKeyDesc(record_to_write.created_order, event_id); 134 135 // the id set retains removed records so merges can carry removals 136 if (existing_cursor_maybe == null) { 137 const issue_id_set_cursor = try haxy_moment.putCursor(hash.hashInt(hash_kind, all_id_set_key)); 138 const issue_id_set = try DB.SortedSet(.read_write).init(issue_id_set_cursor); 139 try issue_id_set.put(&order_key); 140 } 141 142 if (!record_to_write.removed) { 143 const status_set = try statusSet(DB, status_to_issues, record_to_write.event.status); 144 try status_set.put(&order_key); 145 146 var tag_iter = tagIterator(record_to_write.event.tags); 147 while (tag_iter.next()) |tag| { 148 // an over-long tag goes unindexed rather than failing the consume, 149 // which would wedge the branch it arrived on 150 if (tag.len == 0 or tag.len > tag_max_len) continue; 151 var key_buffer: TagStatusKey = undefined; 152 const tag_set_cursor = try tag_to_issues.putCursor(try tagStatusKey(&key_buffer, tag, record_to_write.event.status)); 153 const tag_set = try DB.SortedSet(.read_write).init(tag_set_cursor); 154 try tag_set.put(&order_key); 155 } 156 } 157 } 158 159 // `status`'s sorted set within `statuses`, keyed by status name 160 fn statusSet( 161 comptime DB: type, 162 statuses: DB.SortedMap(.read_write), 163 status: Status, 164 ) !DB.SortedSet(.read_write) { 165 const cursor = try statuses.putCursor(@tagName(status)); 166 return DB.SortedSet(.read_write).init(cursor); 167 } 168 169 // the resolve form's inputs: null fields keep the live values, `hunks` holds 170 // the description conflict hunks' resolutions in chunk order (absent 171 // trailing ones keep our side) 172 pub const Resolve = struct { 173 title: ?[]const u8 = null, 174 tags: ?[]const u8 = null, 175 hunks: []const []const u8 = &.{}, 176 }; 177 178 // what an update replaces on an issue: its status, its content, or — for a 179 // conflicted issue — the resolution of its conflict 180 pub const Update = union(enum) { 181 status: Status, 182 fields: struct { title: []const u8, tags: []const u8, description: []const u8 }, 183 resolve: Resolve, 184 }; 185 186 // re-emit the issue `id_bytes` names with `change` applied, preserving the 187 // rest of its current state. error.NotFound when the id names no issue, 188 // error.InvalidFields when a resolve composes invalid fields. `repo` must be 189 // writable. 190 pub fn update( 191 host_kind: evt.HostKind, 192 comptime repo_kind: rp.RepoKind, 193 comptime repo_opts: rp.RepoOpts(repo_kind), 194 io: std.Io, 195 allocator: std.mem.Allocator, 196 repo: *rp.Repo(repo_kind, repo_opts), 197 id_bytes: *const [evt.event_id_size]u8, 198 change: Update, 199 author: evt.CommitAuthor, 200 ) !void { 201 var arena = std.heap.ArenaAllocator.init(allocator); 202 defer arena.deinit(); 203 204 const issue = (try readById(repo_kind, repo_opts, io, allocator, &arena, repo, id_bytes)) orelse return error.NotFound; 205 if (issue.removed) return error.NotFound; 206 207 var updated = issue.event; 208 switch (change) { 209 .status => |status| { 210 if (updated.status == status) return; 211 updated.status = status; 212 }, 213 .fields => |fields| { 214 updated.title = fields.title; 215 updated.tags = fields.tags; 216 updated.description = fields.description; 217 }, 218 .resolve => |resolve| { 219 updated = try resolveFields(repo_kind, repo_opts, io, allocator, &arena, repo, id_bytes, issue, resolve); 220 if (!fieldsValid(updated.title, updated.tags)) return error.InvalidFields; 221 }, 222 } 223 224 try evt.consume(host_kind, .repo, repo_kind, repo_opts, io, allocator, repo, evt.events_ref, &[_]evt.EventWithId{.{ 225 .id = std.fmt.bytesToHex(id_bytes.*, .lower), 226 .timestamp = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()), 227 .author = author, 228 .event = .{ .issue = updated }, 229 }}); 230 } 231 232 // the live fields with `resolve` applied: submitted title/tags stand in for the 233 // live ones, and a conflicted description is reassembled from the hunk 234 // resolutions against the conflict entry. the chunking is deterministic: the 235 // entry holds immutable slots and any event since would have removed it, so 236 // the hunks match what the resolve form was built from. a settled or 237 // unreadable entry keeps the live description. 238 fn resolveFields( 239 comptime repo_kind: rp.RepoKind, 240 comptime repo_opts: rp.RepoOpts(repo_kind), 241 io: std.Io, 242 allocator: std.mem.Allocator, 243 arena: *std.heap.ArenaAllocator, 244 repo: *rp.Repo(repo_kind, repo_opts), 245 id_bytes: *const [evt.event_id_size]u8, 246 live: Record, 247 resolve: Resolve, 248 ) !Self { 249 const DB = evt.EventDB(repo_opts.hash); 250 const aa = arena.allocator(); 251 252 var updated = live.event; 253 if (resolve.title) |title| updated.title = title; 254 if (resolve.tags) |tags| updated.tags = tags; 255 256 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; 257 defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); 258 const moment = (if (event_db_maybe) |*event_db| 259 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 260 else if (repo_kind == .git) 261 return updated 262 else 263 evt.currentMoment(repo_opts, repo)) catch return updated; 264 265 const conflicts_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, conflicts_key))) orelse return updated; 266 const conflicts = try DB.SortedMap(.read_only).init(conflicts_cursor); 267 const order_key = evt.orderKeyDesc(live.created_order, id_bytes); 268 const conflict_cursor = (try conflicts.getCursor(&order_key)) orelse return updated; 269 const entry = try DB.HashMap(.read_only).init(conflict_cursor); 270 271 const fields_cursor = (try entry.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key))) orelse return updated; 272 const conflicted_fields = try fields_cursor.readBytesAlloc(aa, null); 273 if (std.mem.indexOf(u8, conflicted_fields, "description") == null) return updated; 274 275 const their_cursor = (try entry.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key))) orelse return updated; 276 const theirs = try evt.read(Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(their_cursor)); 277 278 // absent when both sides created the issue independently 279 var base_description: []const u8 = ""; 280 if (try entry.getCursor(hash.hashInt(repo_opts.hash, evt.base_record_key))) |base_cursor| { 281 const base = try evt.read(Record, DB, repo_opts.hash, arena, try DB.HashMap(.read_only).init(base_cursor)); 282 base_description = base.event.description; 283 } 284 285 const chunks = try diff3.chunks(allocator, arena, base_description, live.event.description, theirs.event.description); 286 var resolutions: std.ArrayList([]const u8) = .empty; 287 var hunk_index: usize = 0; 288 for (chunks) |chunk| { 289 if (chunk != .conflict) continue; 290 try resolutions.append(aa, if (hunk_index < resolve.hunks.len) resolve.hunks[hunk_index] else (chunk.conflict.ours orelse "")); 291 hunk_index += 1; 292 } 293 updated.description = try diff3.assemble(aa, chunks, resolutions.items); 294 return updated; 295 } 296 297 // the issue `id_bytes` names in the repo's consumed event db (the repo's own 298 // db for xit; the side db next to a git repo), or null 299 fn readById( 300 comptime repo_kind: rp.RepoKind, 301 comptime repo_opts: rp.RepoOpts(repo_kind), 302 io: std.Io, 303 allocator: std.mem.Allocator, 304 arena: *std.heap.ArenaAllocator, 305 repo: *rp.Repo(repo_kind, repo_opts), 306 id_bytes: *const [evt.event_id_size]u8, 307 ) !?Record { 308 const DB = evt.EventDB(repo_opts.hash); 309 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; 310 defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); 311 const moment = (if (event_db_maybe) |*event_db| 312 evt.currentMomentFromDb(repo_opts.hash, event_db.db) 313 else if (repo_kind == .git) 314 return null 315 else 316 evt.currentMoment(repo_opts, repo)) catch return null; 317 const event_id_to_issue_cursor = (try moment.getCursor(hash.hashInt(repo_opts.hash, record_map_key))) orelse return null; 318 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 319 const issue_cursor = (try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, id_bytes))) orelse return null; 320 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 321 return try evt.read(Record, DB, repo_opts.hash, arena, issue_map); 322 } 323 324 // remove an issue's order key from its tags' `status` sets, pruning entries 325 // whose set becomes empty 326 fn removeFromTagSets( 327 comptime DB: type, 328 tag_to_issues: DB.SortedMap(.read_write), 329 tags: []const u8, 330 status: Status, 331 order_key: []const u8, 332 ) !void { 333 var tag_iter = tagIterator(tags); 334 while (tag_iter.next()) |tag| { 335 // skipped the same way consume skipped it, so it was never indexed 336 if (tag.len == 0 or tag.len > tag_max_len) continue; 337 var key_buffer: TagStatusKey = undefined; 338 const key = try tagStatusKey(&key_buffer, tag, status); 339 const tag_set_cursor = try tag_to_issues.putCursor(key); 340 const tag_set = try DB.SortedSet(.read_write).init(tag_set_cursor); 341 _ = try tag_set.remove(order_key); 342 if (0 == try tag_set.count()) _ = try tag_to_issues.remove(key); 343 } 344 } 345