| 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 milestones_are_renumbered_in_the_workspace_and_what_was_in_them_follows() { |
| 182 | let db = legacy(); |
| 183 | finish(&db); |
| 184 | // Beta (api's, made first) is 1 and Launch (web's) 2 in acme. |
| 185 | let milestones: Vec<(u32, String, String)> = { |
| 186 | let mut statement = |
| 187 | db.prepare("SELECT number, title, origin_repo_id FROM milestones WHERE workspace = 'acme' ORDER BY number").unwrap(); |
| 188 | statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect() |
| 189 | }; |
| 190 | assert_eq!(milestones, [(1, "Beta".to_owned(), "rep_api".to_owned()), (2, "Launch".to_owned(), "rep_web".to_owned())]); |
| 191 | // web's #5 was in web's milestone 1, api's #2 in api's: Launch and Beta. |
| 192 | assert_eq!( |
| 193 | numbers(&db, "SELECT id, milestone FROM work_items WHERE milestone IS NOT NULL ORDER BY id"), |
| 194 | [("iss_api2".to_owned(), Some(1)), ("iss_web5".to_owned(), Some(2))] |
| 195 | ); |
| 196 | assert_eq!(numbers(&db, "SELECT id, milestone FROM pulls WHERE id = 'pr_web4'"), [("pr_web4".to_owned(), Some(2))]); |
| 197 | // Run again, nothing moves twice. |
| 198 | finish(&db); |
| 199 | assert_eq!(numbers(&db, "SELECT id, milestone FROM pulls WHERE id = 'pr_web4'"), [("pr_web4".to_owned(), Some(2))]); |
| 200 | assert_eq!(strings(&db, "SELECT title FROM milestones WHERE workspace = 'acme'").len(), 2); |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn a_reference_is_a_pull_request_here_then_an_old_issue_then_the_workspace() { |
| 205 | let db = legacy(); |
| 206 | finish(&db); |
| 207 | // In web, #4 is its own pull request; #3 its old issue (now #8); #8 is |
| 208 | // that item by its new number; #7 is api's old #1; #3 in api is api's |
| 209 | // pull request. |
| 210 | assert_eq!(reference(&db, Some("rep_web"), "acme", 4), Some(Named::PullHere)); |
| 211 | assert_eq!(reference(&db, Some("rep_web"), "acme", 3), Some(Named::Item("iss_web3".into()))); |
| 212 | assert_eq!(reference(&db, Some("rep_web"), "acme", 8), Some(Named::Item("iss_web3".into()))); |
| 213 | assert_eq!(reference(&db, Some("rep_web"), "acme", 7), Some(Named::Item("iss_api1".into()))); |
| 214 | assert_eq!(reference(&db, Some("rep_api"), "acme", 3), Some(Named::PullHere)); |
| 215 | // An old number of another repository is not this one's: web's #2 is |
| 216 | // its own pull request, api's #2 (now #9) needs api or its new number. |
| 217 | assert_eq!(reference(&db, Some("rep_web"), "acme", 2), Some(Named::PullHere)); |
| 218 | // In the workspace alone: its item, else a pull request of one of its |
| 219 | // repositories. |
| 220 | assert_eq!(reference(&db, None, "acme", 9), Some(Named::Item("iss_api2".into()))); |
| 221 | assert_eq!(reference(&db, None, "acme", 3), Some(Named::PullThere("rep_api".into()))); |
| 222 | assert_eq!(reference(&db, None, "acme", 42), None); |
| 223 | assert_eq!(reference(&db, None, "acme", 2), Some(Named::PullThere("rep_web".into()))); |
| 224 | // Another workspace's numbers are its own: globex's #3 is its item, |
| 225 | // and its pull request #2 is nothing of acme's. |
| 226 | assert_eq!(reference(&db, None, "globex", 3), Some(Named::Item("iss_site1".into()))); |
| 227 | assert_eq!(reference(&db, None, "globex", 2), Some(Named::PullThere("rep_site".into()))); |
| 228 | } |
| 229 | |
| 230 | /// A work item of acme, filed from web, meant to land in web and api. |
| 231 | fn two_targets(db: &Connection) { |
| 232 | db.execute_batch( |
| 233 | "INSERT INTO repo_workspaces (repo_id, workspace, path, private) VALUES |
| 234 | ('rep_web', 'acme', 'acme/web', 0), ('rep_api', 'acme', 'acme/api', 1), ('rep_docs', 'acme', 'acme/docs', 0); |
| 235 | INSERT INTO work_items (id, workspace, number, title, body, author_id, author_name, created_at, updated_at) |
| 236 | VALUES ('wki_1', 'acme', 12, 'Sign in with passkeys', '', 'usr_1', 'ana', '', ''), |
| 237 | ('wki_2', 'acme', 13, 'Typo', '', 'usr_1', 'ana', '', ''); |
| 238 | INSERT INTO work_item_repos (item_id, repo_id, role, added_at) VALUES |
| 239 | ('wki_1', 'rep_web', 'origin', ''), ('wki_1', 'rep_web2', 'target', ''), ('wki_1', 'rep_api', 'target', ''), |
| 240 | ('wki_2', 'rep_docs', 'origin', '');", |
| 241 | ) |
| 242 | .unwrap(); |
| 243 | } |
| 244 | |
| 245 | /// Lands `item` in `repo` by `pull` or `commit`: whether it closed. |
| 246 | fn land(db: &Connection, item: &str, repo: &str, pull: Option<u32>, commit: Option<&str>) -> bool { |
| 247 | let resolution = serde_json::json!({ "repo_id": repo, "pull": pull, "commit": commit, "pushed_by": "ana" }).to_string(); |
| 248 | for sql in &LAND_SQL[..3] { |
| 249 | run(db, sql, &[&item, &repo, &"2026-10-17", &"usr_1", &resolution, &pull, &commit]); |
| 250 | } |
| 251 | db.query_row(LAND_SQL[3], params![item, repo, "2026-10-17"], |row| row.get::<_, String>(0)).optional().unwrap().is_some() |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn a_work_item_closes_once_every_target_has_landed() { |
| 256 | let db = database(); |
| 257 | two_targets(&db); |
| 258 | // Landing where it was filed is not where it was meant to land. |
| 259 | assert!(!land(&db, "wki_1", "rep_web", Some(14), None)); |
| 260 | assert!(!land(&db, "wki_1", "rep_web2", Some(3), None), "one target of two"); |
| 261 | // The same merge told again records nothing twice. |
| 262 | assert!(!land(&db, "wki_1", "rep_web2", Some(3), None)); |
| 263 | assert_eq!(strings(&db, "SELECT state FROM work_items WHERE id = 'wki_1'"), ["open"]); |
| 264 | assert!(land(&db, "wki_1", "rep_api", None, Some("abc123")), "the last target closes it"); |
| 265 | assert_eq!(strings(&db, "SELECT state || ' ' || reason FROM work_items WHERE id = 'wki_1'"), ["closed completed"]); |
| 266 | let resolutions: Vec<serde_json::Value> = |
| 267 | serde_json::from_str(&strings(&db, "SELECT resolutions FROM work_items WHERE id = 'wki_1'")[0]).unwrap(); |
| 268 | let landed: Vec<&str> = resolutions.iter().map(|r| r["repo_id"].as_str().unwrap()).collect(); |
| 269 | assert_eq!(landed, ["rep_web", "rep_web2", "rep_api"]); |
| 270 | assert_eq!( |
| 271 | strings(&db, "SELECT repo_id FROM work_item_repos WHERE item_id = 'wki_1' AND landed_at IS NOT NULL ORDER BY repo_id"), |
| 272 | ["rep_api", "rep_web", "rep_web2"] |
| 273 | ); |
| 274 | // Closed already: landing again closes nothing. |
| 275 | assert!(!land(&db, "wki_1", "rep_api", Some(20), None)); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn with_no_targets_what_closes_it_closes_it_and_another_repository_is_touched() { |
| 280 | let db = database(); |
| 281 | two_targets(&db); |
| 282 | // Fixed from api, which it was not linked to: linked as touched, and, |
| 283 | // with no targets, closed. |
| 284 | assert!(land(&db, "wki_2", "rep_api", Some(5), None)); |
| 285 | assert_eq!( |
| 286 | strings(&db, "SELECT repo_id || ' ' || role FROM work_item_repos WHERE item_id = 'wki_2' ORDER BY repo_id"), |
| 287 | ["rep_api touched", "rep_docs origin"] |
| 288 | ); |
| 289 | } |
| 290 | |
| 291 | #[test] |
| 292 | fn part_of_links_without_closing() { |
| 293 | let db = database(); |
| 294 | two_targets(&db); |
| 295 | let part = |item: &str, repo: &str| -> bool { |
| 296 | db.query_row(PART_SQL, params![item, repo, "2026-10-17", "usr_1"], |row| row.get::<_, String>(0)).optional().unwrap().is_some() |
| 297 | }; |
| 298 | assert!(part("wki_1", "rep_docs"), "linked the first time"); |
| 299 | assert!(!part("wki_1", "rep_docs"), "and once"); |
| 300 | assert!(!part("wki_1", "rep_api"), "a target stays a target"); |
| 301 | assert_eq!(strings(&db, "SELECT role FROM work_item_repos WHERE item_id = 'wki_1' AND repo_id = 'rep_api'"), ["target"]); |
| 302 | assert_eq!(strings(&db, "SELECT state FROM work_items WHERE id = 'wki_1'"), ["open"]); |
| 303 | assert!(strings(&db, "SELECT landed_at FROM work_item_repos WHERE item_id = 'wki_1' AND landed_at IS NOT NULL").is_empty()); |
| 304 | } |
| 305 | |
| 306 | fn next(db: &Connection, workspace: &str, repo: Option<&str>) -> u32 { |
| 307 | db.query_row(NEXT_NUMBER, params![workspace, repo], |row| row.get(0)).unwrap() |
| 308 | } |
| 309 | |
| 310 | #[test] |
| 311 | fn new_numbers_go_on_from_the_workspace_and_never_reuse_a_repositorys() { |
| 312 | let db = legacy(); |
| 313 | finish(&db); |
| 314 | // A pull request of web, then a work item: acme's sequence. |
| 315 | assert_eq!(next(&db, "acme", Some("rep_web")), 11); |
| 316 | assert_eq!(next(&db, "acme", None), 12); |
| 317 | // A repository with pull requests up to #50 moves into acme. |
| 318 | db.execute_batch( |
| 319 | "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at) |
| 320 | VALUES ('pr_big', 'rep_big', 50, 'Old', 'ana', 'external', 'usr_1', 'ana', '', '');", |
| 321 | ) |
| 322 | .unwrap(); |
| 323 | run(&db, REMEMBER_REPO, &[&"rep_big", &"globex", &"globex/big", &0]); |
| 324 | let values = g1t_kit::transfer::values("acme/big", "globex/big", "rep_big"); |
| 325 | for sql in TRANSFERRED { |
| 326 | let binds: Vec<&dyn rusqlite::ToSql> = values.iter().map(|value| value as &dyn rusqlite::ToSql).collect(); |
| 327 | run(&db, sql, &binds); |
| 328 | } |
| 329 | assert_eq!(strings(&db, "SELECT workspace || ' ' || path FROM repo_workspaces WHERE repo_id = 'rep_big'"), ["acme acme/big"]); |
| 330 | assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(50))]); |
| 331 | assert_eq!(next(&db, "acme", Some("rep_big")), 51); |
| 332 | // Even had the transfer not been heard of, its own pull requests' |
| 333 | // numbers are never taken again. |
| 334 | db.execute("UPDATE work_counters SET last = 20 WHERE workspace = 'acme'", []).unwrap(); |
| 335 | assert_eq!(next(&db, "acme", Some("rep_big")), 51); |
| 336 | assert_eq!(next(&db, "acme", Some("rep_web")), 52); |
| 337 | } |
| 338 | |
| 339 | #[test] |
| 340 | fn a_workspaces_first_number_is_after_its_repositories_before_the_job_runs() { |
| 341 | let db = legacy(); |
| 342 | // Pull requests opened while the job runs: after web's own numbers. |
| 343 | assert_eq!(next(&db, "acme", Some("rep_web")), 6); |
| 344 | assert_eq!(next(&db, "acme", Some("rep_api")), 7); |
| 345 | finish(&db); |
| 346 | // The job numbers the moved issues after those. |
| 347 | assert_eq!(numbers(&db, "SELECT id, number FROM work_items WHERE id = 'iss_web1'"), [("iss_web1".to_owned(), Some(8))]); |
| 348 | // Whichever repository opens one first, none takes a number another |
| 349 | // already used for an issue: api's first is 4, and web's after its #5. |
| 350 | let db = legacy(); |
| 351 | assert_eq!(next(&db, "acme", Some("rep_api")), 4); |
| 352 | assert_eq!(next(&db, "acme", Some("rep_web")), 6); |
| 353 | } |