| 1 | //! Issues moving into work items, against the schema the migrations leave: |
| 2 | //! what migrations/0049_work_items.sql moves, what the job then numbers and |
| 3 | //! merges (migration.rs), how `#N` is read (refs.rs `REF_SQL`), what |
| 4 | //! landing does (landing.rs) and where new numbers come from (numbering.rs). |
| 5 | |
| 6 | use rusqlite::{Connection, OptionalExtension, params}; |
| 7 | |
| 8 | use super::landing::LAND_SQL; |
| 9 | use super::links::{PART_SQL, REMEMBER_REPO}; |
| 10 | use super::migration::{RECORD_REPO, TRANSFERRED, finish_sql}; |
| 11 | use super::numbering::NEXT_NUMBER; |
| 12 | use super::refs::{Named, REF_SQL, RefRow}; |
| 13 | use crate::migrated::{database, database_before, migrate_from}; |
| 14 | |
| 15 | const MIGRATION: &str = "0049_work_items.sql"; |
| 16 | |
| 17 | /// Binds `sql` with the first of `values` it names (`?1` up to its |
| 18 | /// highest), as D1 is bound. |
| 19 | fn run(db: &Connection, sql: &str, values: &[&dyn rusqlite::ToSql]) -> usize { |
| 20 | db.execute(sql, &values[..g1t_kit::rename::parameters(sql)]).unwrap() |
| 21 | } |
| 22 | |
| 23 | fn strings(db: &Connection, sql: &str) -> Vec<String> { |
| 24 | let mut statement = db.prepare(sql).unwrap(); |
| 25 | statement.query_map([], |row| row.get::<_, String>(0)).unwrap().map(Result::unwrap).collect() |
| 26 | } |
| 27 | |
| 28 | fn numbers(db: &Connection, sql: &str) -> Vec<(String, Option<u32>)> { |
| 29 | let mut statement = db.prepare(sql).unwrap(); |
| 30 | statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?))).unwrap().map(Result::unwrap).collect() |
| 31 | } |
| 32 | |
| 33 | /// Two repositories of `acme` whose issue and pull request numbers collide |
| 34 | /// (web: issues 1, 3 and 5, pull requests 2 and 4; api: issues 1 and 2, |
| 35 | /// pull request 3), and one of `globex`, as they were before work items. |
| 36 | fn legacy() -> Connection { |
| 37 | let db = database_before(MIGRATION); |
| 38 | db.execute_batch( |
| 39 | "INSERT INTO counters (repo_id, last) VALUES ('rep_web', 5), ('rep_api', 3), ('rep_site', 2); |
| 40 | INSERT INTO issues (id, repo_id, number, title, body, labels, state, reason, resolved_by, blocked_by, milestone, |
| 41 | author_id, author_name, created_at, updated_at, closed_at) |
| 42 | VALUES ('iss_web1', 'rep_web', 1, 'Header', '', '[\"bug\"]', 'open', NULL, NULL, '[]', NULL, 'usr_1', 'ana', '2026-01-01', '2026-01-01', NULL), |
| 43 | ('iss_api1', 'rep_api', 1, 'Rate limits', '', '[]', 'open', NULL, NULL, '[]', NULL, 'usr_1', 'ana', '2026-01-02', '2026-01-02', NULL), |
| 44 | ('iss_web3', 'rep_web', 3, 'Footer', '', '[]', 'closed', 'completed', 4, '[1]', NULL, 'usr_1', 'ana', '2026-01-03', '2026-01-06', '2026-01-06'), |
| 45 | ('iss_api2', 'rep_api', 2, 'Tokens', '', '[]', 'open', NULL, NULL, '[1]', 1, 'usr_2', 'bo', '2026-01-04', '2026-01-04', NULL), |
| 46 | ('iss_web5', 'rep_web', 5, 'Logo', '', '[\"docs\"]', 'open', NULL, NULL, '[1, 3]', 1, 'usr_2', 'bo', '2026-01-05', '2026-01-05', NULL), |
| 47 | ('iss_site1', 'rep_site', 1, 'Site', '', '[]', 'open', NULL, NULL, '[]', NULL, 'usr_1', 'ana', '2026-01-01', '2026-01-01', NULL); |
| 48 | INSERT INTO pulls (id, repo_id, number, issue_id, issue_number, title, agent, runtime, status, closes, milestone, |
| 49 | author_id, author_name, created_at, updated_at) |
| 50 | VALUES ('pr_web2', 'rep_web', 2, NULL, NULL, 'Tidy', 'ana', 'external', 'open', '[]', NULL, 'usr_1', 'ana', '', ''), |
| 51 | ('pr_web4', 'rep_web', 4, 'iss_web3', 3, 'Fix footer', 'ana', 'external', 'merged', '[3, 1]', 1, 'usr_1', 'ana', '', ''), |
| 52 | ('pr_api3', 'rep_api', 3, NULL, NULL, 'Limits', 'ana', 'external', 'open', '[]', NULL, 'usr_1', 'ana', '', ''), |
| 53 | ('pr_site2', 'rep_site', 2, NULL, NULL, 'Site', 'ana', 'external', 'open', '[]', NULL, 'usr_1', 'ana', '', ''); |
| 54 | INSERT INTO comments (id, repo_id, number, author_id, author_name, body, created_at) |
| 55 | VALUES ('cmt_issue', 'rep_web', 3, 'usr_1', 'ana', 'On the footer', ''), |
| 56 | ('cmt_pull', 'rep_web', 4, 'usr_1', 'ana', 'On the fix', ''); |
| 57 | INSERT INTO sessions (id, repo_id, issue, actor_id, actor_name, harness, started_at, last_seen_at) |
| 58 | VALUES ('ses_1', 'rep_api', 2, 'usr_1', 'ana', 'codex', '', ''); |
| 59 | INSERT INTO claims (id, repo_id, issue, actor_id, actor_name, ttl_minutes, created_at, expires_at) |
| 60 | VALUES ('clm_1', 'rep_api', 2, 'usr_1', 'ana', 30, '', ''); |
| 61 | INSERT INTO labels (repo_id, name, color, description, created_at) |
| 62 | VALUES ('rep_web', 'bug', 'd73a4a', 'Broken', '2026-01-01'), ('rep_web', 'docs', '0075ca', 'Words', '2026-01-03'), |
| 63 | ('rep_api', 'bug', '0e8a16', 'Something broke', '2025-12-01'); |
| 64 | INSERT INTO milestones (repo_id, number, title, created_at, updated_at) |
| 65 | VALUES ('rep_web', 1, 'Launch', '2026-01-02', '2026-01-02'), ('rep_api', 1, 'Beta', '2026-01-01', '2026-01-01');", |
| 66 | ) |
| 67 | .unwrap(); |
| 68 | migrate_from(&db, MIGRATION); |
| 69 | db |
| 70 | } |
| 71 | |
| 72 | /// What the job records of each repository, then its last step. |
| 73 | fn finish(db: &Connection) { |
| 74 | for (id, workspace, path) in [("rep_web", "acme", "acme/web"), ("rep_api", "acme", "acme/api"), ("rep_site", "globex", "globex/site")] { |
| 75 | for sql in RECORD_REPO { |
| 76 | run(db, sql, &[&id, &workspace, &path, &0]); |
| 77 | } |
| 78 | } |
| 79 | for sql in finish_sql() { |
| 80 | run(db, &sql, &[&"2026-10-17T00:00:00Z"]); |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | fn reference(db: &Connection, repo: Option<&str>, workspace: &str, number: u32) -> Option<Named> { |
| 85 | let row: RefRow = db |
| 86 | .query_row(REF_SQL, params![repo, workspace, number], |row| { |
| 87 | Ok(RefRow { pull_here: row.get(0)?, legacy: row.get(1)?, item: row.get(2)?, pull_there: row.get(3)? }) |
| 88 | }) |
| 89 | .unwrap(); |
| 90 | row.named(repo.is_some()) |
| 91 | } |
| 92 | |
| 93 | #[test] |
| 94 | fn the_migration_moves_every_issue_and_what_named_it_by_number() { |
| 95 | let db = legacy(); |
| 96 | // Every issue is a work item, not yet in a workspace, filed from its |
| 97 | // repository under its old number. |
| 98 | assert_eq!(numbers(&db, "SELECT id, number FROM work_items ORDER BY id").len(), 6); |
| 99 | assert!(strings(&db, "SELECT id FROM work_items WHERE workspace IS NOT NULL OR number IS NOT NULL").is_empty()); |
| 100 | let origins: Vec<(String, Option<u32>)> = |
| 101 | numbers(&db, "SELECT item_id, legacy_number FROM work_item_repos WHERE role = 'origin' ORDER BY item_id"); |
| 102 | assert_eq!(origins.len(), 6); |
| 103 | assert!(origins.contains(&("iss_web3".to_owned(), Some(3))) && origins.contains(&("iss_api1".to_owned(), Some(1)))); |
| 104 | // Blockers by id, in the same repository: web's #1 and #3, never api's #1. |
| 105 | assert_eq!(strings(&db, "SELECT blocked_by FROM work_items WHERE id = 'iss_web5'"), [r#"["iss_web1","iss_web3"]"#]); |
| 106 | assert_eq!(strings(&db, "SELECT blocked_by FROM work_items WHERE id = 'iss_api2'"), [r#"["iss_api1"]"#]); |
| 107 | // What resolved it, and that it landed where it was filed. |
| 108 | assert_eq!( |
| 109 | strings(&db, "SELECT resolutions FROM work_items WHERE id = 'iss_web3'"), |
| 110 | [r#"[{"repo_id":"rep_web","pull":4,"commit":null,"pushed_by":null}]"#] |
| 111 | ); |
| 112 | assert_eq!(strings(&db, "SELECT landed_at FROM work_item_repos WHERE item_id = 'iss_web3'"), ["2026-01-06"]); |
| 113 | // A pull request names its work item and what it closes by id. |
| 114 | assert_eq!(strings(&db, "SELECT item_id FROM pulls WHERE id = 'pr_web4'"), ["iss_web3"]); |
| 115 | assert_eq!(strings(&db, "SELECT closes_items FROM pulls WHERE id = 'pr_web4'"), [r#"["iss_web3","iss_web1"]"#]); |
| 116 | // An issue's comment is the work item's; a pull request's stays its own. |
| 117 | assert_eq!(strings(&db, "SELECT item_id FROM comments WHERE id = 'cmt_issue' AND repo_id IS NULL"), ["iss_web3"]); |
| 118 | assert_eq!(strings(&db, "SELECT repo_id FROM comments WHERE id = 'cmt_pull' AND item_id IS NULL"), ["rep_web"]); |
| 119 | // Sessions and claims follow the issue by id; the claim keeps its checkout. |
| 120 | assert_eq!(strings(&db, "SELECT item_id FROM sessions WHERE id = 'ses_1'"), ["iss_api2"]); |
| 121 | assert_eq!(strings(&db, "SELECT item_id || ' ' || repo_id FROM claims WHERE id = 'clm_1'"), ["iss_api2 rep_api"]); |
| 122 | // Nothing points at a work item that is not there. |
| 123 | assert!(db.prepare("PRAGMA foreign_key_check").unwrap().query_map([], |_| Ok(())).unwrap().next().is_none()); |
| 124 | // Until the job runs, an old #N is read by its old number. |
| 125 | assert_eq!(reference(&db, Some("rep_web"), "acme", 3), Some(Named::Item("iss_web3".into()))); |
| 126 | assert_eq!(reference(&db, Some("rep_web"), "acme", 4), Some(Named::PullHere)); |
| 127 | assert_eq!(reference(&db, Some("rep_api"), "acme", 1), Some(Named::Item("iss_api1".into()))); |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn the_job_numbers_each_workspace_after_everything_it_used_oldest_first() { |
| 132 | let db = legacy(); |
| 133 | finish(&db); |
| 134 | // acme's repositories used up to 5: its work items go on from 6, in |
| 135 | // the order they were opened, whichever repository they came from. |
| 136 | assert_eq!( |
| 137 | numbers(&db, "SELECT id, number FROM work_items WHERE workspace = 'acme' ORDER BY number"), |
| 138 | [ |
| 139 | ("iss_web1".to_owned(), Some(6)), |
| 140 | ("iss_api1".to_owned(), Some(7)), |
| 141 | ("iss_web3".to_owned(), Some(8)), |
| 142 | ("iss_api2".to_owned(), Some(9)), |
| 143 | ("iss_web5".to_owned(), Some(10)), |
| 144 | ] |
| 145 | ); |
| 146 | assert_eq!(numbers(&db, "SELECT id, number FROM work_items WHERE workspace = 'globex'"), [("iss_site1".to_owned(), Some(3))]); |
| 147 | assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters ORDER BY workspace"), [ |
| 148 | ("acme".to_owned(), Some(10)), |
| 149 | ("globex".to_owned(), Some(3)), |
| 150 | ]); |
| 151 | // The per-repository counters are emptied; the job is done, and running |
| 152 | // its last step again changes nothing. |
| 153 | assert!(strings(&db, "SELECT repo_id FROM counters").is_empty()); |
| 154 | assert_eq!(strings(&db, "SELECT done_at FROM work_jobs WHERE name = 'work_items'"), ["2026-10-17T00:00:00Z"]); |
| 155 | finish(&db); |
| 156 | assert_eq!(numbers(&db, "SELECT id, number FROM work_items WHERE id = 'iss_web5'"), [("iss_web5".to_owned(), Some(10))]); |
| 157 | assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(10))]); |
| 158 | // A pull request's own number and its paths follow. |
| 159 | assert_eq!(strings(&db, "SELECT path FROM work_item_repos WHERE item_id = 'iss_api2'"), ["acme/api"]); |
| 160 | } |
| 161 | |
| 162 | #[test] |
| 163 | fn labels_merge_by_name_and_the_first_made_keeps_its_color() { |
| 164 | let db = legacy(); |
| 165 | finish(&db); |
| 166 | let labels: Vec<(String, String, String)> = { |
| 167 | let mut statement = db.prepare("SELECT name, color, description FROM labels WHERE workspace = 'acme' ORDER BY name").unwrap(); |
| 168 | statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect() |
| 169 | }; |
| 170 | assert_eq!( |
| 171 | labels, |
| 172 | [ |
| 173 | ("bug".to_owned(), "0e8a16".to_owned(), "Something broke".to_owned()), |
| 174 | ("docs".to_owned(), "0075ca".to_owned(), "Words".to_owned()), |
| 175 | ] |
| 176 | ); |
| 177 | assert!(strings(&db, "SELECT name FROM repo_labels").is_empty()); |
| 178 | } |
| 179 | |
| 180 | #[test] |
| 181 | fn labels_merge_whatever_their_case() { |
| 182 | let db = legacy(); |
| 183 | db.execute_batch( |
| 184 | "INSERT INTO repo_labels (repo_id, name, color, description, created_at) |
| 185 | VALUES ('rep_api', 'Docs', 'ffffff', 'Later', '2026-02-01');", |
| 186 | ) |
| 187 | .unwrap(); |
| 188 | finish(&db); |
| 189 | assert_eq!(strings(&db, "SELECT name || ' ' || color FROM labels WHERE workspace = 'acme' AND lower(name) = 'docs'"), ["docs 0075ca"]); |
| 190 | } |
| 191 | |
| 192 | /// Places what the repositories recorded since kept for themselves, as the |
| 193 | /// job for deleted repositories and `repo.restored` do, at `at`. |
| 194 | fn place(db: &Connection, at: &str, carried: bool) { |
| 195 | for sql in super::migration::place_sql() { |
| 196 | run(db, &sql, &[&at]); |
| 197 | } |
| 198 | if carried { |
| 199 | for sql in super::migration::CARRIED_LABELS { |
| 200 | run(db, sql, &[&at]); |
| 201 | } |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | #[test] |
| 206 | fn a_repository_left_out_of_the_job_keeps_what_it_had_until_it_is_recorded() { |
| 207 | let db = legacy(); |
| 208 | // gone was deleted (but restorable) when the job ran: repos did not |
| 209 | // page it, so the job does not record it. |
| 210 | db.execute_batch( |
| 211 | "INSERT INTO counters (repo_id, last) VALUES ('rep_gone', 88); |
| 212 | INSERT INTO repo_labels (repo_id, name, color, description, created_at) |
| 213 | VALUES ('rep_gone', 'ci', '00ff00', 'Builds', '2025-01-01'); |
| 214 | INSERT INTO repo_milestones (repo_id, number, title, created_at, updated_at) |
| 215 | VALUES ('rep_gone', 1, 'Gone', '2025-01-01', '2025-01-01');", |
| 216 | ) |
| 217 | .unwrap(); |
| 218 | finish(&db); |
| 219 | // What it kept is still its own, not dropped with everyone else's. |
| 220 | assert_eq!(strings(&db, "SELECT repo_id FROM counters"), ["rep_gone"]); |
| 221 | assert_eq!(strings(&db, "SELECT name FROM repo_labels"), ["ci"]); |
| 222 | assert_eq!(strings(&db, "SELECT title FROM repo_milestones"), ["Gone"]); |
| 223 | // Recorded later (the job for deleted repositories, or a restore): |
| 224 | // its labels and milestones are acme's, and acme's floor and sequence |
| 225 | // go above its numbers. |
| 226 | for sql in RECORD_REPO { |
| 227 | run(&db, sql, &[&"rep_gone", &"acme", &"acme/gone", &0]); |
| 228 | } |
| 229 | place(&db, "2026-10-18T00:00:00Z", true); |
| 230 | assert_eq!(strings(&db, "SELECT name || ' ' || color FROM labels WHERE workspace = 'acme' AND name = 'ci'"), ["ci 00ff00"]); |
| 231 | assert_eq!(strings(&db, "SELECT title FROM milestones WHERE workspace = 'acme' ORDER BY number"), ["Beta", "Launch", "Gone"]); |
| 232 | assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(88))]); |
| 233 | assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(88))]); |
| 234 | assert!(strings(&db, "SELECT repo_id FROM counters").is_empty()); |
| 235 | // Earlier milestones were not touched. |
| 236 | assert_eq!(numbers(&db, "SELECT id, milestone FROM work_items WHERE milestone IS NOT NULL ORDER BY id"), [ |
| 237 | ("iss_api2".to_owned(), Some(1)), |
| 238 | ("iss_web5".to_owned(), Some(2)), |
| 239 | ]); |
| 240 | // Run again, nothing changes. |
| 241 | place(&db, "2026-10-19T00:00:00Z", true); |
| 242 | assert_eq!(strings(&db, "SELECT title FROM milestones WHERE workspace = 'acme'").len(), 3); |
| 243 | assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(88))]); |
| 244 | } |
| 245 | |
| 246 | /// g1t.sh as the first job left flagon-io's `hello`: deleted when it ran, |
| 247 | /// so its 42 work items were placed (from what else was kept) and |
| 248 | /// numbered, but with no path, as private, its labels dropped unmerged and |
| 249 | /// its old numbers (up to 88) not in the floor. |
| 250 | #[test] |
| 251 | fn the_job_for_deleted_repositories_repairs_what_the_first_left() { |
| 252 | let db = legacy(); |
| 253 | finish(&db); |
| 254 | db.execute_batch( |
| 255 | "INSERT INTO work_items (id, workspace, number, title, body, labels, author_id, author_name, created_at, updated_at) |
| 256 | VALUES ('iss_hello1', 'acme', 11, 'Hello', '', '[\"ci\",\"bug\"]', 'usr_1', 'ana', '', ''), |
| 257 | ('iss_hello88', 'acme', 12, 'Hooks', '', '[\"webhook\"]', 'usr_1', 'ana', '', ''); |
| 258 | INSERT INTO work_item_repos (item_id, repo_id, role, legacy_number, added_at) |
| 259 | VALUES ('iss_hello1', 'rep_hello', 'origin', 1, ''), ('iss_hello88', 'rep_hello', 'origin', 88, ''); |
| 260 | UPDATE work_counters SET last = 156 WHERE workspace = 'acme';", |
| 261 | ) |
| 262 | .unwrap(); |
| 263 | assert_eq!(strings(&db, "SELECT COALESCE(path, '') FROM work_item_repos WHERE repo_id = 'rep_hello' LIMIT 1"), [""]); |
| 264 | // The job records it as repos says it is (public), and places. |
| 265 | for sql in RECORD_REPO { |
| 266 | run(&db, sql, &[&"rep_hello", &"acme", &"acme/hello", &0]); |
| 267 | } |
| 268 | place(&db, "2026-10-18T00:00:00Z", true); |
| 269 | assert_eq!(strings(&db, "SELECT DISTINCT path FROM work_item_repos WHERE repo_id = 'rep_hello'"), ["acme/hello"]); |
| 270 | assert_eq!(strings(&db, "SELECT workspace || ' ' || private FROM repo_workspaces WHERE repo_id = 'rep_hello'"), ["acme 0"]); |
| 271 | // Its labels are acme's (bug was already, with its own color). |
| 272 | assert_eq!(strings(&db, "SELECT name || ' ' || color FROM labels WHERE workspace = 'acme' ORDER BY name"), [ |
| 273 | "bug 0e8a16", |
| 274 | "ci ededed", |
| 275 | "docs 0075ca", |
| 276 | "webhook ededed" |
| 277 | ]); |
| 278 | // Its old numbers are below acme's floor; the sequence, past them |
| 279 | // already, stays where it is. |
| 280 | assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(88))]); |
| 281 | assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(156))]); |
| 282 | // The work items keep their numbers; run again, nothing changes. |
| 283 | assert_eq!(numbers(&db, "SELECT id, number FROM work_items WHERE id LIKE 'iss_hello%' ORDER BY id"), [ |
| 284 | ("iss_hello1".to_owned(), Some(11)), |
| 285 | ("iss_hello88".to_owned(), Some(12)), |
| 286 | ]); |
| 287 | place(&db, "2026-10-19T00:00:00Z", true); |
| 288 | assert_eq!(strings(&db, "SELECT name FROM labels WHERE workspace = 'acme'").len(), 4); |
| 289 | assert_eq!(strings(&db, "SELECT created_at FROM labels WHERE name = 'ci'"), ["2026-10-18T00:00:00Z"]); |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn the_floor_migration_sets_what_the_job_would() { |
| 294 | // A database the job finished before floors were kept: the migration |
| 295 | // gives each workspace the floor the job now sets, counting only pull |
| 296 | // requests opened before it finished. |
| 297 | let db = database_before("0052_work_floors.sql"); |
| 298 | db.execute_batch( |
| 299 | "INSERT INTO repo_workspaces (repo_id, workspace, path, private) VALUES ('rep_web', 'acme', 'acme/web', 0); |
| 300 | INSERT INTO work_counters (workspace, last) VALUES ('acme', 30); |
| 301 | UPDATE work_jobs SET done_at = '2026-10-17T00:00:00Z' WHERE name = 'work_items'; |
| 302 | INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at) |
| 303 | VALUES ('pr_old', 'rep_web', 7, 'Old', 'ana', 'external', 'usr_1', 'ana', '2026-01-01', ''), |
| 304 | ('pr_new', 'rep_web', 29, 'New', 'ana', 'external', 'usr_1', 'ana', '2026-10-18', ''); |
| 305 | INSERT INTO work_items (id, workspace, number, title, body, author_id, author_name, created_at, updated_at) |
| 306 | VALUES ('iss_9', 'acme', 20, 'Old issue', '', 'usr_1', 'ana', '', ''); |
| 307 | INSERT INTO work_item_repos (item_id, repo_id, role, legacy_number, added_at) VALUES ('iss_9', 'rep_web', 'origin', 9, '');", |
| 308 | ) |
| 309 | .unwrap(); |
| 310 | migrate_from(&db, "0052_work_floors.sql"); |
| 311 | assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters"), [("acme".to_owned(), Some(9))]); |
| 312 | // The jobs after it wait their turn: the one 0052 adds, and 0055's. |
| 313 | assert_eq!(strings(&db, "SELECT name FROM work_jobs WHERE done_at IS NULL ORDER BY name"), ["deleted_marks", "deleted_repos"]); |
| 314 | run(&db, super::migration::RAISE_FLOORS, &[&"2026-10-18T12:00:00Z"]); |
| 315 | assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters"), [("acme".to_owned(), Some(9))]); |
| 316 | } |
| 317 | |
| 318 | #[test] |
| 319 | fn milestones_are_renumbered_in_the_workspace_and_what_was_in_them_follows() { |
| 320 | let db = legacy(); |
| 321 | finish(&db); |
| 322 | // Beta (api's, made first) is 1 and Launch (web's) 2 in acme. |
| 323 | let milestones: Vec<(u32, String, String)> = { |
| 324 | let mut statement = |
| 325 | db.prepare("SELECT number, title, origin_repo_id FROM milestones WHERE workspace = 'acme' ORDER BY number").unwrap(); |
| 326 | statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect() |
| 327 | }; |
| 328 | assert_eq!(milestones, [(1, "Beta".to_owned(), "rep_api".to_owned()), (2, "Launch".to_owned(), "rep_web".to_owned())]); |
| 329 | // web's #5 was in web's milestone 1, api's #2 in api's: Launch and Beta. |
| 330 | assert_eq!( |
| 331 | numbers(&db, "SELECT id, milestone FROM work_items WHERE milestone IS NOT NULL ORDER BY id"), |
| 332 | [("iss_api2".to_owned(), Some(1)), ("iss_web5".to_owned(), Some(2))] |
| 333 | ); |
| 334 | assert_eq!(numbers(&db, "SELECT id, milestone FROM pulls WHERE id = 'pr_web4'"), [("pr_web4".to_owned(), Some(2))]); |
| 335 | // Run again, nothing moves twice. |
| 336 | finish(&db); |
| 337 | assert_eq!(numbers(&db, "SELECT id, milestone FROM pulls WHERE id = 'pr_web4'"), [("pr_web4".to_owned(), Some(2))]); |
| 338 | assert_eq!(strings(&db, "SELECT title FROM milestones WHERE workspace = 'acme'").len(), 2); |
| 339 | } |
| 340 | |
| 341 | #[test] |
| 342 | fn a_reference_is_a_pull_request_here_then_an_old_issue_then_the_workspace() { |
| 343 | let db = legacy(); |
| 344 | finish(&db); |
| 345 | // In web, #4 is its own pull request; #3 its old issue (now #8); #8 is |
| 346 | // that item by its new number; #7 is api's old #1; #3 in api is api's |
| 347 | // pull request. |
| 348 | assert_eq!(reference(&db, Some("rep_web"), "acme", 4), Some(Named::PullHere)); |
| 349 | assert_eq!(reference(&db, Some("rep_web"), "acme", 3), Some(Named::Item("iss_web3".into()))); |
| 350 | assert_eq!(reference(&db, Some("rep_web"), "acme", 8), Some(Named::Item("iss_web3".into()))); |
| 351 | assert_eq!(reference(&db, Some("rep_web"), "acme", 7), Some(Named::Item("iss_api1".into()))); |
| 352 | assert_eq!(reference(&db, Some("rep_api"), "acme", 3), Some(Named::PullHere)); |
| 353 | // An old number of another repository is not this one's: web's #2 is |
| 354 | // its own pull request, api's #2 (now #9) needs api or its new number. |
| 355 | assert_eq!(reference(&db, Some("rep_web"), "acme", 2), Some(Named::PullHere)); |
| 356 | // In the workspace alone: its item, else a pull request of one of its |
| 357 | // repositories numbered in the workspace, above its floor (5, the |
| 358 | // most its repositories used on their own). An old pull request's |
| 359 | // number is any of its repositories', and names none of them: api's |
| 360 | // #3 and web's #2 could be another's. |
| 361 | assert_eq!(reference(&db, None, "acme", 9), Some(Named::Item("iss_api2".into()))); |
| 362 | assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters ORDER BY workspace"), [ |
| 363 | ("acme".to_owned(), Some(5)), |
| 364 | ("globex".to_owned(), Some(2)), |
| 365 | ]); |
| 366 | assert_eq!(reference(&db, None, "acme", 3), None); |
| 367 | assert_eq!(reference(&db, None, "acme", 2), None); |
| 368 | assert_eq!(reference(&db, None, "acme", 42), None); |
| 369 | db.execute_batch( |
| 370 | "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at) |
| 371 | VALUES ('pr_api11', 'rep_api', 11, 'New', 'ana', 'external', 'usr_1', 'ana', '2026-10-18', '2026-10-18');", |
| 372 | ) |
| 373 | .unwrap(); |
| 374 | assert_eq!(reference(&db, None, "acme", 11), Some(Named::PullThere("rep_api".into()))); |
| 375 | assert_eq!(reference(&db, Some("rep_web"), "acme", 11), Some(Named::PullThere("rep_api".into()))); |
| 376 | // Another workspace's numbers are its own: globex's #3 is its item, |
| 377 | // and its old pull request #2 is nothing of acme's. |
| 378 | assert_eq!(reference(&db, None, "globex", 3), Some(Named::Item("iss_site1".into()))); |
| 379 | assert_eq!(reference(&db, None, "globex", 2), None); |
| 380 | } |
| 381 | |
| 382 | #[test] |
| 383 | fn an_old_number_of_a_repository_moved_in_names_nothing_of_its_new_workspace() { |
| 384 | let db = legacy(); |
| 385 | finish(&db); |
| 386 | // api moves from acme to globex: its old #1 is acme's #7, which |
| 387 | // globex's text never closes; read in api now, `#1` is no work item. |
| 388 | let values = g1t_kit::transfer::values("globex/api", "acme/api", "rep_api"); |
| 389 | for sql in TRANSFERRED { |
| 390 | let binds: Vec<&dyn rusqlite::ToSql> = values.iter().map(|value| value as &dyn rusqlite::ToSql).collect(); |
| 391 | run(&db, sql, &binds); |
| 392 | } |
| 393 | assert_eq!(reference(&db, Some("rep_api"), "acme", 1), Some(Named::Item("iss_api1".into()))); |
| 394 | assert_eq!(reference(&db, Some("rep_api"), "globex", 1), None); |
| 395 | // And globex's floor rose above api's numbers. |
| 396 | assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters WHERE workspace = 'globex'"), [("globex".to_owned(), Some(3))]); |
| 397 | } |
| 398 | |
| 399 | /// A work item of acme, filed from web, meant to land in web and api. |
| 400 | fn two_targets(db: &Connection) { |
| 401 | db.execute_batch( |
| 402 | "INSERT INTO repo_workspaces (repo_id, workspace, path, private) VALUES |
| 403 | ('rep_web', 'acme', 'acme/web', 0), ('rep_api', 'acme', 'acme/api', 1), ('rep_docs', 'acme', 'acme/docs', 0); |
| 404 | INSERT INTO work_items (id, workspace, number, title, body, author_id, author_name, created_at, updated_at) |
| 405 | VALUES ('wki_1', 'acme', 12, 'Sign in with passkeys', '', 'usr_1', 'ana', '', ''), |
| 406 | ('wki_2', 'acme', 13, 'Typo', '', 'usr_1', 'ana', '', ''); |
| 407 | INSERT INTO work_item_repos (item_id, repo_id, role, added_at) VALUES |
| 408 | ('wki_1', 'rep_web', 'origin', ''), ('wki_1', 'rep_web2', 'target', ''), ('wki_1', 'rep_api', 'target', ''), |
| 409 | ('wki_2', 'rep_docs', 'origin', '');", |
| 410 | ) |
| 411 | .unwrap(); |
| 412 | } |
| 413 | |
| 414 | /// Lands `item` in `repo` by `pull` or `commit`: whether it closed. |
| 415 | fn land(db: &Connection, item: &str, repo: &str, pull: Option<u32>, commit: Option<&str>) -> bool { |
| 416 | let resolution = serde_json::json!({ "repo_id": repo, "pull": pull, "commit": commit, "pushed_by": "ana" }).to_string(); |
| 417 | for sql in &LAND_SQL[..3] { |
| 418 | run(db, sql, &[&item, &repo, &"2026-10-17", &"usr_1", &resolution, &pull, &commit]); |
| 419 | } |
| 420 | db.query_row(LAND_SQL[3], params![item, repo, "2026-10-17"], |row| row.get::<_, String>(0)).optional().unwrap().is_some() |
| 421 | } |
| 422 | |
| 423 | #[test] |
| 424 | fn a_work_item_closes_once_every_target_has_landed() { |
| 425 | let db = database(); |
| 426 | two_targets(&db); |
| 427 | // Landing where it was filed is not where it was meant to land. |
| 428 | assert!(!land(&db, "wki_1", "rep_web", Some(14), None)); |
| 429 | assert!(!land(&db, "wki_1", "rep_web2", Some(3), None), "one target of two"); |
| 430 | // The same merge told again records nothing twice. |
| 431 | assert!(!land(&db, "wki_1", "rep_web2", Some(3), None)); |
| 432 | assert_eq!(strings(&db, "SELECT state FROM work_items WHERE id = 'wki_1'"), ["open"]); |
| 433 | assert!(land(&db, "wki_1", "rep_api", None, Some("abc123")), "the last target closes it"); |
| 434 | assert_eq!(strings(&db, "SELECT state || ' ' || reason FROM work_items WHERE id = 'wki_1'"), ["closed completed"]); |
| 435 | let resolutions: Vec<serde_json::Value> = |
| 436 | serde_json::from_str(&strings(&db, "SELECT resolutions FROM work_items WHERE id = 'wki_1'")[0]).unwrap(); |
| 437 | let landed: Vec<&str> = resolutions.iter().map(|r| r["repo_id"].as_str().unwrap()).collect(); |
| 438 | assert_eq!(landed, ["rep_web", "rep_web2", "rep_api"]); |
| 439 | assert_eq!( |
| 440 | strings(&db, "SELECT repo_id FROM work_item_repos WHERE item_id = 'wki_1' AND landed_at IS NOT NULL ORDER BY repo_id"), |
| 441 | ["rep_api", "rep_web", "rep_web2"] |
| 442 | ); |
| 443 | // Closed already: landing again closes nothing. |
| 444 | assert!(!land(&db, "wki_1", "rep_api", Some(20), None)); |
| 445 | } |
| 446 | |
| 447 | #[test] |
| 448 | fn unlinking_the_last_target_left_to_land_closes_what_landed_everywhere_else() { |
| 449 | let db = database(); |
| 450 | two_targets(&db); |
| 451 | let close = |item: &str| -> bool { |
| 452 | db.query_row(super::links::CLOSE_IF_LANDED, params![item, "2026-10-18"], |row| row.get::<_, String>(0)).optional().unwrap().is_some() |
| 453 | }; |
| 454 | // Nothing landed yet: unlinking targets does not close it. |
| 455 | db.execute("DELETE FROM work_item_repos WHERE item_id = 'wki_2' AND role = 'target'", []).unwrap(); |
| 456 | assert!(!close("wki_2")); |
| 457 | // Landed in web2, still to land in api. |
| 458 | assert!(!land(&db, "wki_1", "rep_web2", Some(3), None)); |
| 459 | assert!(!close("wki_1"), "api is still to land"); |
| 460 | // api is unlinked (or made touched): every target left has landed. |
| 461 | db.execute("UPDATE work_item_repos SET role = 'touched' WHERE item_id = 'wki_1' AND repo_id = 'rep_api'", []).unwrap(); |
| 462 | assert!(close("wki_1")); |
| 463 | assert_eq!(strings(&db, "SELECT state || ' ' || reason FROM work_items WHERE id = 'wki_1'"), ["closed completed"]); |
| 464 | assert!(!close("wki_1"), "once"); |
| 465 | } |
| 466 | |
| 467 | #[test] |
| 468 | fn with_no_targets_what_closes_it_closes_it_and_another_repository_is_touched() { |
| 469 | let db = database(); |
| 470 | two_targets(&db); |
| 471 | // Fixed from api, which it was not linked to: linked as touched, and, |
| 472 | // with no targets, closed. |
| 473 | assert!(land(&db, "wki_2", "rep_api", Some(5), None)); |
| 474 | assert_eq!( |
| 475 | strings(&db, "SELECT repo_id || ' ' || role FROM work_item_repos WHERE item_id = 'wki_2' ORDER BY repo_id"), |
| 476 | ["rep_api touched", "rep_docs origin"] |
| 477 | ); |
| 478 | } |
| 479 | |
| 480 | #[test] |
| 481 | fn part_of_links_without_closing() { |
| 482 | let db = database(); |
| 483 | two_targets(&db); |
| 484 | let part = |item: &str, repo: &str| -> bool { |
| 485 | db.query_row(PART_SQL, params![item, repo, "2026-10-17", "usr_1"], |row| row.get::<_, String>(0)).optional().unwrap().is_some() |
| 486 | }; |
| 487 | assert!(part("wki_1", "rep_docs"), "linked the first time"); |
| 488 | assert!(!part("wki_1", "rep_docs"), "and once"); |
| 489 | assert!(!part("wki_1", "rep_api"), "a target stays a target"); |
| 490 | assert_eq!(strings(&db, "SELECT role FROM work_item_repos WHERE item_id = 'wki_1' AND repo_id = 'rep_api'"), ["target"]); |
| 491 | assert_eq!(strings(&db, "SELECT state FROM work_items WHERE id = 'wki_1'"), ["open"]); |
| 492 | assert!(strings(&db, "SELECT landed_at FROM work_item_repos WHERE item_id = 'wki_1' AND landed_at IS NOT NULL").is_empty()); |
| 493 | } |
| 494 | |
| 495 | fn next(db: &Connection, workspace: &str, repo: Option<&str>) -> u32 { |
| 496 | db.query_row(NEXT_NUMBER, params![workspace, repo], |row| row.get(0)).unwrap() |
| 497 | } |
| 498 | |
| 499 | #[test] |
| 500 | fn new_numbers_go_on_from_the_workspace_and_never_reuse_a_repositorys() { |
| 501 | let db = legacy(); |
| 502 | finish(&db); |
| 503 | // A pull request of web, then a work item: acme's sequence. |
| 504 | assert_eq!(next(&db, "acme", Some("rep_web")), 11); |
| 505 | assert_eq!(next(&db, "acme", None), 12); |
| 506 | // A repository with pull requests up to #50 moves into acme. |
| 507 | db.execute_batch( |
| 508 | "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at) |
| 509 | VALUES ('pr_big', 'rep_big', 50, 'Old', 'ana', 'external', 'usr_1', 'ana', '', '');", |
| 510 | ) |
| 511 | .unwrap(); |
| 512 | run(&db, REMEMBER_REPO, &[&"rep_big", &"globex", &"globex/big", &0]); |
| 513 | let values = g1t_kit::transfer::values("acme/big", "globex/big", "rep_big"); |
| 514 | for sql in TRANSFERRED { |
| 515 | let binds: Vec<&dyn rusqlite::ToSql> = values.iter().map(|value| value as &dyn rusqlite::ToSql).collect(); |
| 516 | run(&db, sql, &binds); |
| 517 | } |
| 518 | assert_eq!(strings(&db, "SELECT workspace || ' ' || path FROM repo_workspaces WHERE repo_id = 'rep_big'"), ["acme acme/big"]); |
| 519 | assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(50))]); |
| 520 | assert_eq!(next(&db, "acme", Some("rep_big")), 51); |
| 521 | // Even had the transfer not been heard of, its own pull requests' |
| 522 | // numbers are never taken again. |
| 523 | db.execute("UPDATE work_counters SET last = 20 WHERE workspace = 'acme'", []).unwrap(); |
| 524 | assert_eq!(next(&db, "acme", Some("rep_big")), 51); |
| 525 | assert_eq!(next(&db, "acme", Some("rep_web")), 52); |
| 526 | } |
| 527 | |
| 528 | #[test] |
| 529 | fn a_workspaces_first_number_is_after_its_repositories_before_the_job_runs() { |
| 530 | let db = legacy(); |
| 531 | // Pull requests opened while the job runs: after web's own numbers. |
| 532 | assert_eq!(next(&db, "acme", Some("rep_web")), 6); |
| 533 | assert_eq!(next(&db, "acme", Some("rep_api")), 7); |
| 534 | finish(&db); |
| 535 | // The job numbers the moved issues after those. |
| 536 | assert_eq!(numbers(&db, "SELECT id, number FROM work_items WHERE id = 'iss_web1'"), [("iss_web1".to_owned(), Some(8))]); |
| 537 | // Whichever repository opens one first, none takes a number another |
| 538 | // already used for an issue, even before the job has recorded which |
| 539 | // repositories are acme's: api's first is after web's #5 too. |
| 540 | let db = legacy(); |
| 541 | assert_eq!(next(&db, "acme", Some("rep_api")), 6); |
| 542 | assert_eq!(next(&db, "acme", Some("rep_web")), 7); |
| 543 | } |
| 544 | |
| 545 | #[test] |
| 546 | fn a_pull_request_never_takes_an_old_issue_number_of_its_repository() { |
| 547 | let db = legacy(); |
| 548 | finish(&db); |
| 549 | // A repository whose issues went up to #100 but pull requests to #5 |
| 550 | // moves into acme (counter 10): its next pull request is #101, never |
| 551 | // #11, which `#11` in it names (its old issue). |
| 552 | db.execute_batch( |
| 553 | "INSERT INTO work_items (id, workspace, number, title, body, author_id, author_name, created_at, updated_at) |
| 554 | VALUES ('iss_far100', 'globex', 40, 'Old', '', 'usr_1', 'ana', '', ''); |
| 555 | INSERT INTO work_item_repos (item_id, repo_id, role, legacy_number, added_at) |
| 556 | VALUES ('iss_far100', 'rep_far', 'origin', 100, ''); |
| 557 | INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at) |
| 558 | VALUES ('pr_far5', 'rep_far', 5, 'Old', 'ana', 'external', 'usr_1', 'ana', '', '');", |
| 559 | ) |
| 560 | .unwrap(); |
| 561 | run(&db, REMEMBER_REPO, &[&"rep_far", &"globex", &"globex/far", &0]); |
| 562 | let values = g1t_kit::transfer::values("acme/far", "globex/far", "rep_far"); |
| 563 | for sql in TRANSFERRED { |
| 564 | let binds: Vec<&dyn rusqlite::ToSql> = values.iter().map(|value| value as &dyn rusqlite::ToSql).collect(); |
| 565 | run(&db, sql, &binds); |
| 566 | } |
| 567 | assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(100))]); |
| 568 | // Even had the transfer not been heard of. |
| 569 | db.execute("UPDATE work_counters SET last = 10 WHERE workspace = 'acme'", []).unwrap(); |
| 570 | assert_eq!(next(&db, "acme", Some("rep_far")), 101); |
| 571 | } |