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.

571 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 // 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]
319fn 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]
342fn 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]
383fn 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.
400fn 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.
415fn 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]
424fn 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]
448fn 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]
468fn 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]
481fn 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
495fn 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]
500fn 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]
529fn 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]
546fn 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}