Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

570 linesCodeBlameRaw
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
6use rusqlite::{Connection, OptionalExtension, params};
7
8use super::landing::LAND_SQL;
9use super::links::{PART_SQL, REMEMBER_REPO};
10use super::migration::{RECORD_REPO, TRANSFERRED, finish_sql};
11use super::numbering::NEXT_NUMBER;
12use super::refs::{Named, REF_SQL, RefRow};
13use crate::migrated::{database, database_before, migrate_from};
14
15const 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.
19fn run(db: &Connection, sql: &str, values: &[&dyn rusqlite::ToSql]) -> usize {
20 db.execute(sql, &values[..g1t_kit::rename::parameters(sql)]).unwrap()
21}
22
23fn 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
28fn 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.
36fn 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.
73fn 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
84fn 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]
94fn 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]
131fn 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]
163fn 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]
181fn 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`.
194fn 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]
206fn 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]
251fn 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]
293fn 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 assert_eq!(strings(&db, "SELECT name FROM work_jobs WHERE done_at IS NULL"), ["deleted_repos"]);
313 run(&db, super::migration::RAISE_FLOORS, &[&"2026-10-18T12:00:00Z"]);
314 assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters"), [("acme".to_owned(), Some(9))]);
315}
316
317#[test]
318fn milestones_are_renumbered_in_the_workspace_and_what_was_in_them_follows() {
319 let db = legacy();
320 finish(&db);
321 // Beta (api's, made first) is 1 and Launch (web's) 2 in acme.
322 let milestones: Vec<(u32, String, String)> = {
323 let mut statement =
324 db.prepare("SELECT number, title, origin_repo_id FROM milestones WHERE workspace = 'acme' ORDER BY number").unwrap();
325 statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap().map(Result::unwrap).collect()
326 };
327 assert_eq!(milestones, [(1, "Beta".to_owned(), "rep_api".to_owned()), (2, "Launch".to_owned(), "rep_web".to_owned())]);
328 // web's #5 was in web's milestone 1, api's #2 in api's: Launch and Beta.
329 assert_eq!(
330 numbers(&db, "SELECT id, milestone FROM work_items WHERE milestone IS NOT NULL ORDER BY id"),
331 [("iss_api2".to_owned(), Some(1)), ("iss_web5".to_owned(), Some(2))]
332 );
333 assert_eq!(numbers(&db, "SELECT id, milestone FROM pulls WHERE id = 'pr_web4'"), [("pr_web4".to_owned(), Some(2))]);
334 // Run again, nothing moves twice.
335 finish(&db);
336 assert_eq!(numbers(&db, "SELECT id, milestone FROM pulls WHERE id = 'pr_web4'"), [("pr_web4".to_owned(), Some(2))]);
337 assert_eq!(strings(&db, "SELECT title FROM milestones WHERE workspace = 'acme'").len(), 2);
338}
339
340#[test]
341fn a_reference_is_a_pull_request_here_then_an_old_issue_then_the_workspace() {
342 let db = legacy();
343 finish(&db);
344 // In web, #4 is its own pull request; #3 its old issue (now #8); #8 is
345 // that item by its new number; #7 is api's old #1; #3 in api is api's
346 // pull request.
347 assert_eq!(reference(&db, Some("rep_web"), "acme", 4), Some(Named::PullHere));
348 assert_eq!(reference(&db, Some("rep_web"), "acme", 3), Some(Named::Item("iss_web3".into())));
349 assert_eq!(reference(&db, Some("rep_web"), "acme", 8), Some(Named::Item("iss_web3".into())));
350 assert_eq!(reference(&db, Some("rep_web"), "acme", 7), Some(Named::Item("iss_api1".into())));
351 assert_eq!(reference(&db, Some("rep_api"), "acme", 3), Some(Named::PullHere));
352 // An old number of another repository is not this one's: web's #2 is
353 // its own pull request, api's #2 (now #9) needs api or its new number.
354 assert_eq!(reference(&db, Some("rep_web"), "acme", 2), Some(Named::PullHere));
355 // In the workspace alone: its item, else a pull request of one of its
356 // repositories numbered in the workspace, above its floor (5, the
357 // most its repositories used on their own). An old pull request's
358 // number is any of its repositories', and names none of them: api's
359 // #3 and web's #2 could be another's.
360 assert_eq!(reference(&db, None, "acme", 9), Some(Named::Item("iss_api2".into())));
361 assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters ORDER BY workspace"), [
362 ("acme".to_owned(), Some(5)),
363 ("globex".to_owned(), Some(2)),
364 ]);
365 assert_eq!(reference(&db, None, "acme", 3), None);
366 assert_eq!(reference(&db, None, "acme", 2), None);
367 assert_eq!(reference(&db, None, "acme", 42), None);
368 db.execute_batch(
369 "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at)
370 VALUES ('pr_api11', 'rep_api', 11, 'New', 'ana', 'external', 'usr_1', 'ana', '2026-10-18', '2026-10-18');",
371 )
372 .unwrap();
373 assert_eq!(reference(&db, None, "acme", 11), Some(Named::PullThere("rep_api".into())));
374 assert_eq!(reference(&db, Some("rep_web"), "acme", 11), Some(Named::PullThere("rep_api".into())));
375 // Another workspace's numbers are its own: globex's #3 is its item,
376 // and its old pull request #2 is nothing of acme's.
377 assert_eq!(reference(&db, None, "globex", 3), Some(Named::Item("iss_site1".into())));
378 assert_eq!(reference(&db, None, "globex", 2), None);
379}
380
381#[test]
382fn an_old_number_of_a_repository_moved_in_names_nothing_of_its_new_workspace() {
383 let db = legacy();
384 finish(&db);
385 // api moves from acme to globex: its old #1 is acme's #7, which
386 // globex's text never closes; read in api now, `#1` is no work item.
387 let values = g1t_kit::transfer::values("globex/api", "acme/api", "rep_api");
388 for sql in TRANSFERRED {
389 let binds: Vec<&dyn rusqlite::ToSql> = values.iter().map(|value| value as &dyn rusqlite::ToSql).collect();
390 run(&db, sql, &binds);
391 }
392 assert_eq!(reference(&db, Some("rep_api"), "acme", 1), Some(Named::Item("iss_api1".into())));
393 assert_eq!(reference(&db, Some("rep_api"), "globex", 1), None);
394 // And globex's floor rose above api's numbers.
395 assert_eq!(numbers(&db, "SELECT workspace, floor FROM work_counters WHERE workspace = 'globex'"), [("globex".to_owned(), Some(3))]);
396}
397
398/// A work item of acme, filed from web, meant to land in web and api.
399fn two_targets(db: &Connection) {
400 db.execute_batch(
401 "INSERT INTO repo_workspaces (repo_id, workspace, path, private) VALUES
402 ('rep_web', 'acme', 'acme/web', 0), ('rep_api', 'acme', 'acme/api', 1), ('rep_docs', 'acme', 'acme/docs', 0);
403 INSERT INTO work_items (id, workspace, number, title, body, author_id, author_name, created_at, updated_at)
404 VALUES ('wki_1', 'acme', 12, 'Sign in with passkeys', '', 'usr_1', 'ana', '', ''),
405 ('wki_2', 'acme', 13, 'Typo', '', 'usr_1', 'ana', '', '');
406 INSERT INTO work_item_repos (item_id, repo_id, role, added_at) VALUES
407 ('wki_1', 'rep_web', 'origin', ''), ('wki_1', 'rep_web2', 'target', ''), ('wki_1', 'rep_api', 'target', ''),
408 ('wki_2', 'rep_docs', 'origin', '');",
409 )
410 .unwrap();
411}
412
413/// Lands `item` in `repo` by `pull` or `commit`: whether it closed.
414fn land(db: &Connection, item: &str, repo: &str, pull: Option<u32>, commit: Option<&str>) -> bool {
415 let resolution = serde_json::json!({ "repo_id": repo, "pull": pull, "commit": commit, "pushed_by": "ana" }).to_string();
416 for sql in &LAND_SQL[..3] {
417 run(db, sql, &[&item, &repo, &"2026-10-17", &"usr_1", &resolution, &pull, &commit]);
418 }
419 db.query_row(LAND_SQL[3], params![item, repo, "2026-10-17"], |row| row.get::<_, String>(0)).optional().unwrap().is_some()
420}
421
422#[test]
423fn a_work_item_closes_once_every_target_has_landed() {
424 let db = database();
425 two_targets(&db);
426 // Landing where it was filed is not where it was meant to land.
427 assert!(!land(&db, "wki_1", "rep_web", Some(14), None));
428 assert!(!land(&db, "wki_1", "rep_web2", Some(3), None), "one target of two");
429 // The same merge told again records nothing twice.
430 assert!(!land(&db, "wki_1", "rep_web2", Some(3), None));
431 assert_eq!(strings(&db, "SELECT state FROM work_items WHERE id = 'wki_1'"), ["open"]);
432 assert!(land(&db, "wki_1", "rep_api", None, Some("abc123")), "the last target closes it");
433 assert_eq!(strings(&db, "SELECT state || ' ' || reason FROM work_items WHERE id = 'wki_1'"), ["closed completed"]);
434 let resolutions: Vec<serde_json::Value> =
435 serde_json::from_str(&strings(&db, "SELECT resolutions FROM work_items WHERE id = 'wki_1'")[0]).unwrap();
436 let landed: Vec<&str> = resolutions.iter().map(|r| r["repo_id"].as_str().unwrap()).collect();
437 assert_eq!(landed, ["rep_web", "rep_web2", "rep_api"]);
438 assert_eq!(
439 strings(&db, "SELECT repo_id FROM work_item_repos WHERE item_id = 'wki_1' AND landed_at IS NOT NULL ORDER BY repo_id"),
440 ["rep_api", "rep_web", "rep_web2"]
441 );
442 // Closed already: landing again closes nothing.
443 assert!(!land(&db, "wki_1", "rep_api", Some(20), None));
444}
445
446#[test]
447fn unlinking_the_last_target_left_to_land_closes_what_landed_everywhere_else() {
448 let db = database();
449 two_targets(&db);
450 let close = |item: &str| -> bool {
451 db.query_row(super::links::CLOSE_IF_LANDED, params![item, "2026-10-18"], |row| row.get::<_, String>(0)).optional().unwrap().is_some()
452 };
453 // Nothing landed yet: unlinking targets does not close it.
454 db.execute("DELETE FROM work_item_repos WHERE item_id = 'wki_2' AND role = 'target'", []).unwrap();
455 assert!(!close("wki_2"));
456 // Landed in web2, still to land in api.
457 assert!(!land(&db, "wki_1", "rep_web2", Some(3), None));
458 assert!(!close("wki_1"), "api is still to land");
459 // api is unlinked (or made touched): every target left has landed.
460 db.execute("UPDATE work_item_repos SET role = 'touched' WHERE item_id = 'wki_1' AND repo_id = 'rep_api'", []).unwrap();
461 assert!(close("wki_1"));
462 assert_eq!(strings(&db, "SELECT state || ' ' || reason FROM work_items WHERE id = 'wki_1'"), ["closed completed"]);
463 assert!(!close("wki_1"), "once");
464}
465
466#[test]
467fn with_no_targets_what_closes_it_closes_it_and_another_repository_is_touched() {
468 let db = database();
469 two_targets(&db);
470 // Fixed from api, which it was not linked to: linked as touched, and,
471 // with no targets, closed.
472 assert!(land(&db, "wki_2", "rep_api", Some(5), None));
473 assert_eq!(
474 strings(&db, "SELECT repo_id || ' ' || role FROM work_item_repos WHERE item_id = 'wki_2' ORDER BY repo_id"),
475 ["rep_api touched", "rep_docs origin"]
476 );
477}
478
479#[test]
480fn part_of_links_without_closing() {
481 let db = database();
482 two_targets(&db);
483 let part = |item: &str, repo: &str| -> bool {
484 db.query_row(PART_SQL, params![item, repo, "2026-10-17", "usr_1"], |row| row.get::<_, String>(0)).optional().unwrap().is_some()
485 };
486 assert!(part("wki_1", "rep_docs"), "linked the first time");
487 assert!(!part("wki_1", "rep_docs"), "and once");
488 assert!(!part("wki_1", "rep_api"), "a target stays a target");
489 assert_eq!(strings(&db, "SELECT role FROM work_item_repos WHERE item_id = 'wki_1' AND repo_id = 'rep_api'"), ["target"]);
490 assert_eq!(strings(&db, "SELECT state FROM work_items WHERE id = 'wki_1'"), ["open"]);
491 assert!(strings(&db, "SELECT landed_at FROM work_item_repos WHERE item_id = 'wki_1' AND landed_at IS NOT NULL").is_empty());
492}
493
494fn next(db: &Connection, workspace: &str, repo: Option<&str>) -> u32 {
495 db.query_row(NEXT_NUMBER, params![workspace, repo], |row| row.get(0)).unwrap()
496}
497
498#[test]
499fn new_numbers_go_on_from_the_workspace_and_never_reuse_a_repositorys() {
500 let db = legacy();
501 finish(&db);
502 // A pull request of web, then a work item: acme's sequence.
503 assert_eq!(next(&db, "acme", Some("rep_web")), 11);
504 assert_eq!(next(&db, "acme", None), 12);
505 // A repository with pull requests up to #50 moves into acme.
506 db.execute_batch(
507 "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at)
508 VALUES ('pr_big', 'rep_big', 50, 'Old', 'ana', 'external', 'usr_1', 'ana', '', '');",
509 )
510 .unwrap();
511 run(&db, REMEMBER_REPO, &[&"rep_big", &"globex", &"globex/big", &0]);
512 let values = g1t_kit::transfer::values("acme/big", "globex/big", "rep_big");
513 for sql in TRANSFERRED {
514 let binds: Vec<&dyn rusqlite::ToSql> = values.iter().map(|value| value as &dyn rusqlite::ToSql).collect();
515 run(&db, sql, &binds);
516 }
517 assert_eq!(strings(&db, "SELECT workspace || ' ' || path FROM repo_workspaces WHERE repo_id = 'rep_big'"), ["acme acme/big"]);
518 assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(50))]);
519 assert_eq!(next(&db, "acme", Some("rep_big")), 51);
520 // Even had the transfer not been heard of, its own pull requests'
521 // numbers are never taken again.
522 db.execute("UPDATE work_counters SET last = 20 WHERE workspace = 'acme'", []).unwrap();
523 assert_eq!(next(&db, "acme", Some("rep_big")), 51);
524 assert_eq!(next(&db, "acme", Some("rep_web")), 52);
525}
526
527#[test]
528fn a_workspaces_first_number_is_after_its_repositories_before_the_job_runs() {
529 let db = legacy();
530 // Pull requests opened while the job runs: after web's own numbers.
531 assert_eq!(next(&db, "acme", Some("rep_web")), 6);
532 assert_eq!(next(&db, "acme", Some("rep_api")), 7);
533 finish(&db);
534 // The job numbers the moved issues after those.
535 assert_eq!(numbers(&db, "SELECT id, number FROM work_items WHERE id = 'iss_web1'"), [("iss_web1".to_owned(), Some(8))]);
536 // Whichever repository opens one first, none takes a number another
537 // already used for an issue, even before the job has recorded which
538 // repositories are acme's: api's first is after web's #5 too.
539 let db = legacy();
540 assert_eq!(next(&db, "acme", Some("rep_api")), 6);
541 assert_eq!(next(&db, "acme", Some("rep_web")), 7);
542}
543
544#[test]
545fn a_pull_request_never_takes_an_old_issue_number_of_its_repository() {
546 let db = legacy();
547 finish(&db);
548 // A repository whose issues went up to #100 but pull requests to #5
549 // moves into acme (counter 10): its next pull request is #101, never
550 // #11, which `#11` in it names (its old issue).
551 db.execute_batch(
552 "INSERT INTO work_items (id, workspace, number, title, body, author_id, author_name, created_at, updated_at)
553 VALUES ('iss_far100', 'globex', 40, 'Old', '', 'usr_1', 'ana', '', '');
554 INSERT INTO work_item_repos (item_id, repo_id, role, legacy_number, added_at)
555 VALUES ('iss_far100', 'rep_far', 'origin', 100, '');
556 INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, created_at, updated_at)
557 VALUES ('pr_far5', 'rep_far', 5, 'Old', 'ana', 'external', 'usr_1', 'ana', '', '');",
558 )
559 .unwrap();
560 run(&db, REMEMBER_REPO, &[&"rep_far", &"globex", &"globex/far", &0]);
561 let values = g1t_kit::transfer::values("acme/far", "globex/far", "rep_far");
562 for sql in TRANSFERRED {
563 let binds: Vec<&dyn rusqlite::ToSql> = values.iter().map(|value| value as &dyn rusqlite::ToSql).collect();
564 run(&db, sql, &binds);
565 }
566 assert_eq!(numbers(&db, "SELECT workspace, last FROM work_counters WHERE workspace = 'acme'"), [("acme".to_owned(), Some(100))]);
567 // Even had the transfer not been heard of.
568 db.execute("UPDATE work_counters SET last = 10 WHERE workspace = 'acme'", []).unwrap();
569 assert_eq!(next(&db, "acme", Some("rep_far")), 101);
570}