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