╭ event.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 rf = xit.ref; 6 const hash = xit.hash; 7 8 const author = evt.CommitAuthor{ .name = "haxy", .email = "user@haxy" }; 9 10 test "rebase" { 11 const io = std.testing.io; 12 const allocator = std.testing.allocator; 13 14 // create the temp dir 15 var temp = std.testing.tmpDir(.{}); 16 defer temp.cleanup(); 17 const temp_path = try temp.dir.realPathFileAlloc(io, ".", allocator); 18 defer allocator.free(temp_path); 19 20 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 21 const Repo = rp.Repo(.xit, repo_opts); 22 var repo = try Repo.init(io, allocator, .{ .path = temp_path }); 23 defer repo.deinit(io, allocator); 24 25 var arena = std.heap.ArenaAllocator.init(allocator); 26 defer arena.deinit(); 27 28 // 29 // define test events 30 // 31 32 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 33 34 const first_event_id = evt.EventWithId.randomId(prng.random()); 35 36 const events_to_consume = [_]evt.EventWithId{ 37 .{ 38 .id = std.fmt.bytesToHex(first_event_id, .lower), 39 .author = author, 40 .event = .{ 41 .issue = .{ 42 .title = "Login form clears password on validation error", 43 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 44 .tags = "bug priority-high ui", 45 }, 46 }, 47 }, 48 // this event edits the previous one because it has the same id 49 .{ 50 .id = std.fmt.bytesToHex(first_event_id, .lower), 51 .author = author, 52 .event = .{ 53 .issue = .{ 54 .title = "Login form clears password on validation error", 55 .description = "Submitting an invalid email address resets the password field and removes typed input. Preserve the field value and show an inline validation message.", 56 .tags = "bug priority-low ui", 57 }, 58 }, 59 }, 60 .{ 61 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 62 .author = author, 63 .event = .{ 64 .issue = .{ 65 .title = "Search results ignore archived project filter", 66 .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.", 67 .tags = "bug search backend", 68 }, 69 }, 70 }, 71 .{ 72 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 73 .author = author, 74 .event = .{ 75 .issue = .{ 76 .title = "Issue list does not persist selected sort order", 77 .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.", 78 .tags = "enhancement frontend preferences", 79 }, 80 }, 81 }, 82 }; 83 84 // 85 // insert issues as commits in the repo 86 // 87 88 var first_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 89 90 { 91 var json: std.Io.Writer.Allocating = .init(allocator); 92 defer json.deinit(); 93 94 for (events_to_consume, 0..) |event, i| { 95 json.clearRetainingCapacity(); 96 97 try std.json.Stringify.value(event, .{}, &json.writer); 98 99 // commit the event into a special branch 100 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 101 if (i == 0) { 102 _ = try std.fmt.hexToBytes(&first_oid, &oid); 103 } 104 } 105 } 106 107 // 108 // consume events into the database 109 // 110 111 { 112 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 113 114 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 115 116 // get the map of issues 117 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 118 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 119 120 // get the issue out of the map that was edited 121 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id)) orelse return error.NotFound; 122 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 123 const first_issue = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, first_issue_map); 124 125 // the description was correctly edited 126 try std.testing.expectEqualStrings(events_to_consume[1].event.issue.?.description, first_issue.event.description); 127 128 // the tags were correctly edited 129 try std.testing.expectEqualStrings(events_to_consume[1].event.issue.?.tags, first_issue.event.tags); 130 } 131 132 // 133 // add another event 134 // 135 136 const events_to_consume2 = [_]evt.EventWithId{ 137 .{ 138 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 139 .author = author, 140 .event = .{ 141 .issue = .{ 142 .title = "Double clicking causes the form to submit twice", 143 .description = "When I double click the submit button I see duplicate submissions.", 144 .tags = "bug priority-high ui", 145 }, 146 }, 147 }, 148 }; 149 150 // commit and consume the new event 151 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &events_to_consume2); 152 153 // 154 // rebase the branch so it no longer includes the edit event 155 // 156 157 const events_to_consume3 = [_]evt.EventWithId{ 158 events_to_consume[2], 159 events_to_consume[3], 160 }; 161 162 { 163 var json: std.Io.Writer.Allocating = .init(allocator); 164 defer json.deinit(); 165 166 for (events_to_consume3, 0..) |event, i| { 167 json.clearRetainingCapacity(); 168 169 try std.json.Stringify.value(event, .{}, &json.writer); 170 171 // commit the event into a special branch 172 _ = try repo.commitAtRef( 173 io, 174 allocator, 175 .{ 176 .message = json.written(), 177 // the first event should have the first oid as its parent, 178 // because we want to keep the very first commit. 179 // and the rest will have null which causes them 180 // to have the previous commit as their parent 181 .parent_oids = if (i == 0) &.{std.fmt.bytesToHex(first_oid, .lower)} else null, 182 }, 183 null, 184 evt.events_ref, 185 ); 186 } 187 } 188 189 // 190 // consume events into the database 191 // 192 193 { 194 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 195 196 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 197 198 // get the map of issues 199 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 200 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 201 202 // get the issue out of the map that was edited 203 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id)) orelse return error.NotFound; 204 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 205 const first_issue = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, first_issue_map); 206 207 // the description is no longer edited 208 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.event.description); 209 210 // the tags are no longer edited 211 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.event.tags); 212 213 // an event added by the second push is no longer there because it was wiped out by the rebase 214 try std.testing.expect(null == try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &events_to_consume2[0].id))); 215 } 216 217 // 218 // rebase the branch so it no longer includes the original issue 219 // 220 221 { 222 var json: std.Io.Writer.Allocating = .init(allocator); 223 defer json.deinit(); 224 225 for (events_to_consume3, 0..) |event, i| { 226 json.clearRetainingCapacity(); 227 228 try std.json.Stringify.value(event, .{}, &json.writer); 229 230 // commit the event into a special branch 231 _ = try repo.commitAtRef( 232 io, 233 allocator, 234 .{ 235 .message = json.written(), 236 // the first event should have no parents, 237 // because we want to drop the very first commit. 238 // and the rest will have null which causes them 239 // to have the previous commit as their parent 240 .parent_oids = if (i == 0) &.{} else null, 241 }, 242 null, 243 evt.events_ref, 244 ); 245 } 246 } 247 248 // 249 // consume events into the database 250 // 251 252 { 253 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 254 255 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 256 257 // get the map of issues 258 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 259 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 260 261 // the first issue no longer exists 262 try std.testing.expect(null == try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_event_id))); 263 } 264 } 265 266 test "merge" { 267 const io = std.testing.io; 268 const allocator = std.testing.allocator; 269 270 const other_events_ref: rf.Ref = .{ .kind = .head, .name = "haxy/other" }; 271 272 // create the temp dir 273 var temp = std.testing.tmpDir(.{}); 274 defer temp.cleanup(); 275 const temp_path = try temp.dir.realPathFileAlloc(io, ".", allocator); 276 defer allocator.free(temp_path); 277 278 const repo_opts: rp.RepoOpts(.xit) = .{ .is_test = true }; 279 const Repo = rp.Repo(.xit, repo_opts); 280 var repo = try Repo.init(io, allocator, .{ .path = temp_path }); 281 defer repo.deinit(io, allocator); 282 283 var arena = std.heap.ArenaAllocator.init(allocator); 284 defer arena.deinit(); 285 286 // 287 // define test events 288 // 289 290 var prng = std.Random.DefaultPrng.init(std.testing.random_seed); 291 292 const events_to_consume = [_]evt.EventWithId{ 293 .{ 294 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 295 .author = author, 296 .event = .{ 297 .issue = .{ 298 .title = "Login form clears password on validation error", 299 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 300 .tags = "bug priority-high ui", 301 }, 302 }, 303 }, 304 .{ 305 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 306 .author = author, 307 .event = .{ 308 .issue = .{ 309 .title = "Search results ignore archived project filter", 310 .description = "Filtering search results to active projects still returns issues from archived projects. Apply the archived flag before ranking results.", 311 .tags = "bug search backend", 312 }, 313 }, 314 }, 315 .{ 316 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 317 .author = author, 318 .event = .{ 319 .issue = .{ 320 .title = "Issue list does not persist selected sort order", 321 .description = "Changing the issue list sort order is lost after refresh. Store the selected sort field and direction with the user's view preferences.", 322 .tags = "enhancement frontend preferences", 323 }, 324 }, 325 }, 326 }; 327 328 // 329 // insert issues as commits in the repo 330 // 331 332 var first_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 333 334 { 335 var json: std.Io.Writer.Allocating = .init(allocator); 336 defer json.deinit(); 337 338 for (events_to_consume, 0..) |event, i| { 339 json.clearRetainingCapacity(); 340 341 try std.json.Stringify.value(event, .{}, &json.writer); 342 343 // commit the event into a special branch 344 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 345 if (i == 0) { 346 _ = try std.fmt.hexToBytes(&first_oid, &oid); 347 } 348 } 349 } 350 351 // 352 // consume events into the database 353 // 354 355 { 356 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 357 358 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 359 360 // get the map of issues 361 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 362 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 363 364 // get the issue out of the map 365 var first_issue_id: [evt.event_id_size]u8 = undefined; 366 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume[0].id); 367 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 368 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 369 const first_issue = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, first_issue_map); 370 371 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.event.description); 372 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.event.tags); 373 } 374 375 // 376 // define more test events 377 // 378 379 const events_to_consume2 = [_]evt.EventWithId{ 380 .{ 381 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 382 .author = author, 383 .event = .{ 384 .issue = .{ 385 .title = "Kanban card status badge falls behind after drag", 386 .description = "Moving an issue between columns updates the card position immediately, but the status badge keeps showing the previous state until refresh.", 387 .tags = "bug kanban frontend", 388 }, 389 }, 390 }, 391 .{ 392 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 393 .author = author, 394 .event = .{ 395 .issue = .{ 396 .title = "Assignee autocomplete omits recently invited users", 397 .description = "Users invited during the current session do not appear in the assignee picker until the project page is reloaded.", 398 .tags = "bug assignees api", 399 }, 400 }, 401 }, 402 .{ 403 .id = std.fmt.bytesToHex(evt.EventWithId.randomId(prng.random()), .lower), 404 .author = author, 405 .event = .{ 406 .issue = .{ 407 .title = "Add due date warning for issues blocked by dependencies", 408 .description = "Show a warning when an issue's due date is earlier than an unresolved blocking issue so planners can adjust the schedule.", 409 .tags = "enhancement planning dependencies", 410 }, 411 }, 412 }, 413 }; 414 415 // 416 // insert issues as commits in the repo 417 // 418 419 { 420 var json: std.Io.Writer.Allocating = .init(allocator); 421 defer json.deinit(); 422 423 for (events_to_consume2, 0..) |event, i| { 424 json.clearRetainingCapacity(); 425 426 try std.json.Stringify.value(event, .{}, &json.writer); 427 428 // commit the event into a special branch 429 _ = try repo.commitAtRef( 430 io, 431 allocator, 432 // make the first commit a child of the first commit on the events branch 433 .{ .parent_oids = if (i == 0) &.{std.fmt.bytesToHex(first_oid, .lower)} else null, .message = json.written() }, 434 null, 435 other_events_ref, 436 ); 437 } 438 } 439 440 // 441 // check out the events branch and merge the other events branch into it 442 // 443 444 const merge_oid = blk: { 445 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 446 defer result.deinit(); 447 448 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 449 defer merge.deinit(); 450 451 try std.testing.expect(.success == merge.result); 452 453 break :blk merge.result.success.oid; 454 }; 455 456 // 457 // consume events into the database 458 // 459 460 { 461 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 462 463 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 464 465 // get the map of issues 466 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 467 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 468 469 // make sure one of the original issues is still there 470 { 471 // get the issue out of the map 472 var first_issue_id: [evt.event_id_size]u8 = undefined; 473 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume[0].id); 474 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 475 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 476 const first_issue = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, first_issue_map); 477 478 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.description, first_issue.event.description); 479 try std.testing.expectEqualStrings(events_to_consume[0].event.issue.?.tags, first_issue.event.tags); 480 } 481 482 // make sure one of the new issues is there 483 { 484 // get the issue out of the map 485 var first_issue_id: [evt.event_id_size]u8 = undefined; 486 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 487 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)) orelse return error.NotFound; 488 const first_issue_map = try Repo.DB.HashMap(.read_only).init(first_issue_cursor); 489 const first_issue = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, first_issue_map); 490 491 try std.testing.expectEqualStrings(events_to_consume2[0].event.issue.?.description, first_issue.event.description); 492 try std.testing.expectEqualStrings(events_to_consume2[0].event.issue.?.tags, first_issue.event.tags); 493 } 494 495 // the ordered issue set unions both branches' additions 496 { 497 const status_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "status->issue-id-set")) orelse return error.NotFound; 498 const status_to_issues = try Repo.DB.SortedMap(.read_only).init(status_to_issues_cursor); 499 const issue_id_set_cursor = try status_to_issues.getCursor("open") orelse return error.NotFound; 500 const issue_id_set = try Repo.DB.SortedSet(.read_only).init(issue_id_set_cursor); 501 try std.testing.expectEqual(6, try issue_id_set.count()); 502 503 var found = [_]bool{false} ** 6; 504 var iter = try issue_id_set.iteratorFromIndex(0); 505 while (try iter.next()) |id_cursor_val| { 506 var id_cursor = id_cursor_val; 507 const id_kv = try id_cursor.readKeyValuePair(); 508 var order_key: [@sizeOf(u64) + evt.event_id_size]u8 = undefined; 509 _ = try id_kv.key_cursor.readBytes(&order_key); 510 const id_hex = std.fmt.bytesToHex(order_key[@sizeOf(u64)..].*, .lower); 511 for (events_to_consume, 0..) |event, i| { 512 if (std.mem.eql(u8, &id_hex, &event.id)) found[i] = true; 513 } 514 for (events_to_consume2, 0..) |event, i| { 515 if (std.mem.eql(u8, &id_hex, &event.id)) found[events_to_consume.len + i] = true; 516 } 517 } 518 for (found) |f| try std.testing.expect(f); 519 } 520 } 521 522 // 523 // insert issues as commits in the repo 524 // 525 526 { 527 var json: std.Io.Writer.Allocating = .init(allocator); 528 defer json.deinit(); 529 530 for (events_to_consume, 0..) |event, i| { 531 json.clearRetainingCapacity(); 532 533 try std.json.Stringify.value(event, .{}, &json.writer); 534 535 // commit the event into a special branch 536 _ = try repo.commitAtRef( 537 io, 538 allocator, 539 // make the first event have no parents so we completely rebase the branch, 540 // undoing the merge that we did above 541 .{ .parent_oids = if (i == 0) &.{} else null, .message = json.written() }, 542 null, 543 evt.events_ref, 544 ); 545 } 546 } 547 548 // 549 // consume events into the database 550 // 551 552 { 553 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 554 555 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 556 557 // get the map of issues 558 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "event-id->issue")) orelse return error.NotFound; 559 const event_id_to_issue = try Repo.DB.HashMap(.read_only).init(event_id_to_issue_cursor); 560 561 // make sure the new issue is not there 562 { 563 // get the issue out of the map 564 var first_issue_id: [evt.event_id_size]u8 = undefined; 565 _ = try std.fmt.hexToBytes(&first_issue_id, &events_to_consume2[0].id); 566 const first_issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(repo_opts.hash, &first_issue_id)); 567 try std.testing.expect(null == first_issue_cursor); 568 } 569 } 570 571 // 572 // define test events that modify the same issue in conflicting ways 573 // 574 575 const events_to_consume3 = [_]evt.EventWithId{ 576 .{ 577 .id = events_to_consume[0].id, 578 .author = author, 579 .event = .{ 580 .issue = .{ 581 .title = "Login form clears password on validation error", 582 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 583 .tags = "bug priority-low ui", 584 }, 585 }, 586 }, 587 .{ 588 .id = events_to_consume[0].id, 589 .author = author, 590 .event = .{ 591 .issue = .{ 592 .title = "Login form clears password on validation error", 593 .description = "Submitting an invalid email address resets the password field. Preserve the field value and show an inline validation message.", 594 .tags = "bug priority-medium ui", 595 }, 596 }, 597 }, 598 }; 599 600 // 601 // insert issues as commits in the repo on different branches 602 // 603 604 var conflicting_oids: [events_to_consume3.len][hash.byteLen(repo_opts.hash)]u8 = undefined; 605 606 { 607 var json: std.Io.Writer.Allocating = .init(allocator); 608 defer json.deinit(); 609 610 for (events_to_consume3, 0..) |event, i| { 611 json.clearRetainingCapacity(); 612 613 try std.json.Stringify.value(event, .{}, &json.writer); 614 615 // commit the event into a special branch 616 const oid = try repo.commitAtRef( 617 io, 618 allocator, 619 .{ .parent_oids = &.{merge_oid}, .message = json.written() }, 620 null, 621 switch (i) { 622 0 => evt.events_ref, 623 1 => other_events_ref, 624 else => unreachable, 625 }, 626 ); 627 _ = try std.fmt.hexToBytes(&conflicting_oids[i], &oid); 628 } 629 } 630 631 // 632 // check out the events branch and merge the other events branch into it 633 // 634 635 const conflicting_merge_oid = blk: { 636 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 637 defer result.deinit(); 638 639 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 640 defer merge.deinit(); 641 642 try std.testing.expect(.success == merge.result); 643 644 break :blk merge.result.success.oid; 645 }; 646 647 // 648 // consume events into the database 649 // 650 651 var issue_id: [evt.event_id_size]u8 = undefined; 652 _ = try std.fmt.hexToBytes(&issue_id, &events_to_consume[0].id); 653 654 { 655 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 656 657 // consuming the merge again must be a no-op rather than replaying it 658 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 659 660 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 661 662 // the first parent is the events branch, so its version stays live 663 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 664 try std.testing.expectEqualStrings("bug priority-low ui", issue.event.tags); 665 666 // the issue is listed as conflicted, with the merge base recorded 667 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 668 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 669 try std.testing.expectEqual(1, try conflicts.count()); 670 671 const order_key = evt.orderKeyDesc(issue.created_order, &issue_id); 672 const conflict_cursor = try conflicts.getCursor(&order_key) orelse return error.NotFound; 673 const conflict = try Repo.DB.HashMap(.read_only).init(conflict_cursor); 674 675 const base_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.base_record_key)) orelse return error.NotFound; 676 const base = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, try Repo.DB.HashMap(.read_only).init(base_cursor)); 677 try std.testing.expectEqualStrings("bug priority-high ui", base.event.tags); 678 679 // tags is the one field both sides changed differently 680 const fields_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.conflicted_fields_key)) orelse return error.NotFound; 681 const conflicted_fields = try fields_cursor.readBytesAlloc(arena.allocator(), null); 682 try std.testing.expectEqualStrings("tags", conflicted_fields); 683 684 // their whole version is recorded, not just the conflicting field 685 const their_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.their_record_key)) orelse return error.NotFound; 686 const theirs = try evt.read(evt.Issue.Record, Repo.DB, repo_opts.hash, &arena, try Repo.DB.HashMap(.read_only).init(their_cursor)); 687 try std.testing.expectEqualStrings("bug priority-medium ui", theirs.event.tags); 688 try std.testing.expectEqualStrings(issue.event.title, theirs.event.title); 689 try std.testing.expectEqualStrings(issue.event.description, theirs.event.description); 690 691 // their value is attributed to the commit that set it on their branch 692 const oids_cursor = try conflict.getCursor(hash.hashInt(repo_opts.hash, evt.their_field_to_oid_key)) orelse return error.NotFound; 693 const oids = try Repo.DB.SortedMap(.read_only).init(oids_cursor); 694 const oid_cursor = try oids.getCursor("tags") orelse return error.NotFound; 695 var their_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 696 _ = try oid_cursor.readBytes(&their_oid); 697 try std.testing.expectEqualSlices(u8, &conflicting_oids[1], &their_oid); 698 699 // ours stays attributed to the commit on the events branch 700 try std.testing.expectEqualSlices(u8, &conflicting_oids[0], &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &issue_id, "tags")); 701 702 // the losing side's tag didn't leak into the tag index 703 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 704 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 705 try std.testing.expect(null != try tag_to_issues.getCursor("priority-low open")); 706 try std.testing.expect(null == try tag_to_issues.getCursor("priority-medium open")); 707 } 708 709 // 710 // a merge that touches the issue without conflicting leaves the conflict 711 // alone, since only events settle one 712 // 713 714 { 715 const their_issue = events_to_consume3[1].event.issue orelse return error.NotFound; 716 717 var json: std.Io.Writer.Allocating = .init(allocator); 718 defer json.deinit(); 719 720 // the other branch never saw the conflict and rewrites an unrelated 721 // field 722 const event = evt.EventWithId{ .id = events_to_consume3[1].id, .author = author, .event = .{ .issue = .{ 723 .title = their_issue.title, 724 .description = "Rewritten on a branch that never merged.", 725 .tags = their_issue.tags, 726 } } }; 727 try std.json.Stringify.value(event, .{}, &json.writer); 728 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 729 730 { 731 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 732 defer result.deinit(); 733 734 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 735 defer merge.deinit(); 736 737 try std.testing.expect(.success == merge.result); 738 } 739 740 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 741 742 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 743 744 // their edit came over, so the merge really did touch the issue 745 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 746 try std.testing.expectEqualStrings("Rewritten on a branch that never merged.", issue.event.description); 747 748 // and the conflict is still waiting to be resolved 749 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 750 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 751 try std.testing.expectEqual(1, try conflicts.count()); 752 } 753 754 // 755 // any event settles the conflict, even one that changes no conflicting 756 // field 757 // 758 759 { 760 try evt.Issue.update(.xit, repo_opts, io, allocator, &repo, &issue_id, .{ .status = .closed }, author); 761 762 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 763 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 764 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 765 try std.testing.expectEqual(0, try conflicts.count()); 766 } 767 768 // 769 // edit the issue, so the merge below has a resolution to preserve 770 // 771 772 { 773 try evt.Issue.update(.xit, repo_opts, io, allocator, &repo, &issue_id, .{ .fields = .{ 774 .title = "Login form clears password on validation error", 775 .tags = "bug priority-medium ui", 776 .description = "Submitting an invalid email address resets the password field.", 777 } }, author); 778 } 779 780 // 781 // merging a branch that still holds the settled conflict must not bring it 782 // back 783 // 784 785 { 786 const conflicted_issue = events_to_consume3[0].event.issue orelse return error.NotFound; 787 788 var json: std.Io.Writer.Allocating = .init(allocator); 789 defer json.deinit(); 790 791 // the other branch changes the record on a commit that predates the 792 // resolution, so it still carries the conflict 793 const event = evt.EventWithId{ .id = events_to_consume3[0].id, .author = author, .event = .{ .issue = .{ 794 .title = conflicted_issue.title, 795 .description = conflicted_issue.description, 796 .tags = conflicted_issue.tags, 797 .status = .closed, 798 } } }; 799 try std.json.Stringify.value(event, .{}, &json.writer); 800 _ = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{conflicting_merge_oid}, .message = json.written() }, null, other_events_ref); 801 802 { 803 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 804 defer result.deinit(); 805 806 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 807 defer merge.deinit(); 808 809 try std.testing.expect(.success == merge.result); 810 } 811 812 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 813 814 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 815 816 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 817 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 818 try std.testing.expectEqual(0, try conflicts.count()); 819 820 // and the resolution stands 821 const issue = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &issue_id); 822 try std.testing.expectEqualStrings("bug priority-medium ui", issue.event.tags); 823 } 824 825 // 826 // diverge again, with each side editing different fields of one issue and 827 // only the other side touching a second issue 828 // 829 830 const second_issue = events_to_consume[1].event.issue orelse return error.NotFound; 831 const third_issue = events_to_consume[2].event.issue orelse return error.NotFound; 832 833 var retitle_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 834 var rewrite_oid: [hash.byteLen(repo_opts.hash)]u8 = undefined; 835 836 { 837 const base_oid = try repo.readRef(io, evt.events_ref) orelse return error.NotFound; 838 839 var json: std.Io.Writer.Allocating = .init(allocator); 840 defer json.deinit(); 841 842 // the events branch only retitles the second issue 843 { 844 const event = evt.EventWithId{ .id = events_to_consume[1].id, .author = author, .event = .{ .issue = .{ 845 .title = "Search results ignore the archived project filter", 846 .description = second_issue.description, 847 .tags = second_issue.tags, 848 } } }; 849 try std.json.Stringify.value(event, .{}, &json.writer); 850 const oid = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 851 _ = try std.fmt.hexToBytes(&retitle_oid, &oid); 852 } 853 854 // the other branch rewrites the second issue's description and drops one 855 // of its tags, then retags the third issue 856 { 857 json.clearRetainingCapacity(); 858 const event = evt.EventWithId{ .id = events_to_consume[1].id, .author = author, .event = .{ .issue = .{ 859 .title = second_issue.title, 860 .description = "Archived projects are ranked before the flag is applied.", 861 .tags = "bug search", 862 } } }; 863 try std.json.Stringify.value(event, .{}, &json.writer); 864 const oid = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{base_oid}, .message = json.written() }, null, other_events_ref); 865 _ = try std.fmt.hexToBytes(&rewrite_oid, &oid); 866 } 867 { 868 json.clearRetainingCapacity(); 869 const event = evt.EventWithId{ .id = events_to_consume[2].id, .author = author, .event = .{ .issue = .{ 870 .title = third_issue.title, 871 .description = third_issue.description, 872 .tags = "enhancement roadmap", 873 } } }; 874 try std.json.Stringify.value(event, .{}, &json.writer); 875 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 876 } 877 } 878 879 { 880 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 881 defer result.deinit(); 882 883 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 884 defer merge.deinit(); 885 886 try std.testing.expect(.success == merge.result); 887 } 888 889 { 890 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 891 892 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 893 894 var second_id: [evt.event_id_size]u8 = undefined; 895 _ = try std.fmt.hexToBytes(&second_id, &events_to_consume[1].id); 896 897 // each side's edit to a different field survives, without conflicting 898 const merged = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &second_id); 899 try std.testing.expectEqualStrings("Search results ignore the archived project filter", merged.event.title); 900 try std.testing.expectEqualStrings("Archived projects are ranked before the flag is applied.", merged.event.description); 901 try std.testing.expectEqualStrings("bug search", merged.event.tags); 902 903 // each merged field is attributed to the branch it came from 904 try std.testing.expectEqualSlices(u8, &retitle_oid, &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &second_id, "title")); 905 try std.testing.expectEqualSlices(u8, &rewrite_oid, &try readOid(Repo.DB, repo_opts.hash, haxy_moment, &second_id, "description")); 906 907 const conflicts_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, evt.Issue.conflicts_key)) orelse return error.NotFound; 908 const conflicts = try Repo.DB.SortedMap(.read_only).init(conflicts_cursor); 909 try std.testing.expectEqual(0, try conflicts.count()); 910 911 // tags the merged-in side dropped don't survive in the tag index, 912 // whether we edited that issue or left it alone 913 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 914 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 915 916 var third_id: [evt.event_id_size]u8 = undefined; 917 _ = try std.fmt.hexToBytes(&third_id, &events_to_consume[2].id); 918 const retagged = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &third_id); 919 try std.testing.expectEqualStrings("enhancement roadmap", retagged.event.tags); 920 921 try std.testing.expect(null == try tag_to_issues.getCursor("backend open")); 922 try std.testing.expect(null == try tag_to_issues.getCursor("preferences open")); 923 try std.testing.expect(null != try tag_to_issues.getCursor("roadmap open")); 924 try std.testing.expect(null != try tag_to_issues.getCursor("enhancement open")); 925 } 926 927 // 928 // a removal on the other branch carries over, unless we edited the issue, 929 // in which case the edit wins 930 // 931 932 { 933 const edited_issue = events_to_consume2[1].event.issue orelse return error.NotFound; 934 935 const base_oid = try repo.readRef(io, evt.events_ref) orelse return error.NotFound; 936 937 var json: std.Io.Writer.Allocating = .init(allocator); 938 defer json.deinit(); 939 940 // we retitle one of the two the other branch is about to drop 941 { 942 const event = evt.EventWithId{ .id = events_to_consume2[1].id, .author = author, .event = .{ .issue = .{ 943 .title = "Kept by the edit", 944 .description = edited_issue.description, 945 .tags = edited_issue.tags, 946 } } }; 947 try std.json.Stringify.value(event, .{}, &json.writer); 948 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, evt.events_ref); 949 } 950 951 // the other branch removes both 952 { 953 json.clearRetainingCapacity(); 954 const event = evt.EventWithId{ .id = events_to_consume2[0].id, .author = author, .event = .{ .issue = null } }; 955 try std.json.Stringify.value(event, .{}, &json.writer); 956 _ = try repo.commitAtRef(io, allocator, .{ .parent_oids = &.{base_oid}, .message = json.written() }, null, other_events_ref); 957 } 958 { 959 json.clearRetainingCapacity(); 960 const event = evt.EventWithId{ .id = events_to_consume2[1].id, .author = author, .event = .{ .issue = null } }; 961 try std.json.Stringify.value(event, .{}, &json.writer); 962 _ = try repo.commitAtRef(io, allocator, .{ .message = json.written() }, null, other_events_ref); 963 } 964 965 { 966 var result = try repo.switchDir(io, allocator, .{ .target = .{ .ref = evt.events_ref } }); 967 defer result.deinit(); 968 969 var merge = try repo.merge(io, allocator, .{ .kind = .full, .action = .{ .new = .{ .source = &.{.{ .ref = other_events_ref }} } } }, null); 970 defer merge.deinit(); 971 972 try std.testing.expect(.success == merge.result); 973 } 974 975 try evt.consume(.repo, .xit, repo_opts, io, allocator, &repo, evt.events_ref, &.{}); 976 977 const haxy_moment = try evt.currentMoment(repo_opts, &repo); 978 979 // the one we left alone is removed, and its tags left the index 980 var dropped_id: [evt.event_id_size]u8 = undefined; 981 _ = try std.fmt.hexToBytes(&dropped_id, &events_to_consume2[0].id); 982 const dropped = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &dropped_id); 983 try std.testing.expect(dropped.removed); 984 985 const tag_to_issues_cursor = try haxy_moment.getCursor(hash.hashInt(repo_opts.hash, "tag+status->issue-id-set")) orelse return error.NotFound; 986 const tag_to_issues = try Repo.DB.SortedMap(.read_only).init(tag_to_issues_cursor); 987 try std.testing.expect(null == try tag_to_issues.getCursor("kanban open")); 988 989 // the one we edited survives, with our edit 990 var kept_id: [evt.event_id_size]u8 = undefined; 991 _ = try std.fmt.hexToBytes(&kept_id, &events_to_consume2[1].id); 992 const kept = try readIssue(Repo.DB, repo_opts.hash, haxy_moment, &arena, &kept_id); 993 try std.testing.expect(!kept.removed); 994 try std.testing.expectEqualStrings("Kept by the edit", kept.event.title); 995 } 996 } 997 998 // the commit that last set `field_name` on the issue `id` names 999 fn readOid( 1000 comptime DB: type, 1001 comptime hash_kind: hash.HashKind, 1002 haxy_moment: DB.HashMap(.read_only), 1003 id: *const [evt.event_id_size]u8, 1004 comptime field_name: []const u8, 1005 ) ![hash.byteLen(hash_kind)]u8 { 1006 const id_to_field_to_oid_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, evt.Issue.id_to_field_to_oid_key)) orelse return error.NotFound; 1007 const id_to_field_to_oid = try DB.HashMap(.read_only).init(id_to_field_to_oid_cursor); 1008 const field_oids_cursor = try id_to_field_to_oid.getCursor(hash.hashInt(hash_kind, id)) orelse return error.NotFound; 1009 const field_oids = try DB.SortedMap(.read_only).init(field_oids_cursor); 1010 const cursor = try field_oids.getCursor(field_name) orelse return error.NotFound; 1011 var oid: [hash.byteLen(hash_kind)]u8 = undefined; 1012 _ = try cursor.readBytes(&oid); 1013 return oid; 1014 } 1015 1016 // the issue `id` names, read out of `event-id->issue` 1017 fn readIssue( 1018 comptime DB: type, 1019 comptime hash_kind: hash.HashKind, 1020 haxy_moment: DB.HashMap(.read_only), 1021 arena: *std.heap.ArenaAllocator, 1022 id: *const [evt.event_id_size]u8, 1023 ) !evt.Issue.Record { 1024 const event_id_to_issue_cursor = try haxy_moment.getCursor(hash.hashInt(hash_kind, "event-id->issue")) orelse return error.NotFound; 1025 const event_id_to_issue = try DB.HashMap(.read_only).init(event_id_to_issue_cursor); 1026 const issue_cursor = try event_id_to_issue.getCursor(hash.hashInt(hash_kind, id)) orelse return error.NotFound; 1027 const issue_map = try DB.HashMap(.read_only).init(issue_cursor); 1028 return try evt.read(evt.Issue.Record, DB, hash_kind, arena, issue_map); 1029 } 1030