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.

g1t.sh
538 linesCodeBlameRaw
1//! What a push to a branch settles, as a merge from the site would.
2//!
3//! An open pull request into the branch whose head the push brought in was
4//! merged by it: it is recorded as merged by whoever pushed, through the
5//! same bookkeeping as any merge (`Work::record_merge`). A push that landed
6//! a pull request names it (`GitPush::landed_pull`): whoever landed it
7//! records that one, and the merge queue's entry being landed is left to
8//! the queue.
9//!
10//! A commit pushed to the default branch whose message names an issue
11//! after a closing keyword (`Fixes #12`, closing.rs) closes it, where the
12//! repository closes issues that way, and the issue says which commit and
13//! who pushed it.
14
15use g1t_contracts::events::{Event, IssueEvent};
16use g1t_contracts::repos::{BranchDriftArgs, BranchDrifts, Commit, GetByIdArgs, Landed, LogArgs, Pushed, PushedArgs, Repo, RepoPath};
17use g1t_contracts::rules::MergeMethod;
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::work::*;
20use g1t_contracts::{FailureCode, Outcome, User, Viewer};
21use g1t_kit::now_ms;
22use serde::Deserialize;
23use worker::Result;
24use worker::wasm_bindgen::JsValue;
25
26use crate::{Work, closing};
27
28/// How many open pull requests into a branch are looked at after a push.
29const MAX_CANDIDATES: u32 = 100;
30
31/// An open pull request into the pushed branch, and the head it is at.
32#[derive(Deserialize)]
33struct CandidateRow {
34 id: String,
35 head_commit: String,
36}
37
38/// The open pull requests into a branch (`None`: the default branch) that
39/// a push may have merged: those with a head, but not the one the push
40/// landed, nor one the merge queue is landing.
41const CANDIDATES: &str = "SELECT id, head_commit FROM pulls
42 WHERE repo_id = ?1 AND status IN ('draft', 'open') AND base_branch IS ?2 AND head_commit IS NOT NULL
43 AND number IS NOT ?3
44 AND NOT EXISTS (SELECT 1 FROM queue_entries q
45 WHERE q.pull_id = pulls.id AND q.state = 'passed' AND q.landing_commit IS NOT NULL)
46 ORDER BY updated_at DESC LIMIT ?4";
47
48/// Closes an open issue as done by a commit (`?1`), pushed by `?2`.
49const CLOSE_BY_COMMIT: &str = "UPDATE issues
50 SET state = 'closed', reason = 'completed', resolved_by = NULL, resolved_commit = ?1,
51 resolved_pushed_by = ?2, closed_at = ?3, updated_at = ?4
52 WHERE id = ?5 AND state = 'open' RETURNING id AS value";
53
54/// A session (`?1`) of a repository (`?2`).
55const SESSION_IN_REPO: &str = "SELECT id AS value FROM sessions WHERE id = ?1 AND repo_id = ?2";
56
57/// The session, still going, of the caller (`?3`, or the person it acts
58/// for, `?4`) that works on issue `?2` of a repository (`?1`): attached to
59/// it or tied to it since; the one seen last.
60const CALLER_SESSION: &str = "SELECT id AS value FROM sessions
61 WHERE repo_id = ?1 AND state = 'active' AND actor_id IN (?3, ?4)
62 AND (issue = ?2 OR EXISTS (SELECT 1 FROM session_links l
63 WHERE l.session_id = sessions.id AND l.kind = 'issue' AND l.number = ?2))
64 ORDER BY last_seen_at DESC LIMIT 1";
65
66/// A note of what closed an issue, and the session it was closed in.
67pub(crate) fn closed_note(note: &str, session: Option<&str>) -> String {
68 match session {
69 Some(session) => format!("{note} via session {session}"),
70 None => note.to_owned(),
71 }
72}
73
74/// The issues of a repository a commit closed.
75const CLOSED_BY_COMMIT: &str = "SELECT number, title FROM issues WHERE repo_id = ?1 AND resolved_commit = ?2
76 ORDER BY number LIMIT 50";
77
78/// The issues a push's commits close, each with the first commit, oldest
79/// first, that names it: in the order named, each once, no more than one
80/// pull request may close.
81pub(crate) fn commit_closes(commits: &[g1t_contracts::repos::PushedCommit], repo: &RepoPath) -> Vec<(u32, String)> {
82 let mut closes: Vec<(u32, String)> = Vec::new();
83 for commit in commits {
84 for number in closing::references(&commit.message, repo) {
85 if !closes.iter().any(|(named, _)| *named == number) {
86 closes.push((number, commit.hash.clone()));
87 }
88 }
89 }
90 closes.truncate(closing::MAX_CLOSES);
91 closes
92}
93
94impl Work {
95 /// After a push moved `branch` of a repository: records the pull
96 /// requests into it that the push merged, and, on the default branch,
97 /// closes the issues its commits name. Returns the pull requests it
98 /// recorded as merged. Never fails the push's handling: a problem is
99 /// logged.
100 pub(crate) async fn after_branch_push(&self, event: &Event, repo_id: &str, branch: &str, after: &str) -> Vec<String> {
101 match self.settle_push(event, repo_id, branch, after).await {
102 Ok(merged) => merged,
103 Err(error) => {
104 worker::console_error!("push to {branch} of {repo_id}: merges and closes not recorded: {error}");
105 Vec::new()
106 }
107 }
108 }
109
110 async fn settle_push(&self, event: &Event, repo_id: &str, branch: &str, after: &str) -> Result<Vec<String>> {
111 // Who pushed is who merged; a push nobody made (an import) settles nothing.
112 let Some(actor_id) = event.actor.as_deref() else { return Ok(Vec::new()) };
113 let default = event.data["defaultBranch"].as_bool() == Some(true);
114 let landed_pull = event.data["landedPull"].as_u64().and_then(|n| u32::try_from(n).ok());
115 let base = if default { JsValue::NULL } else { branch.into() };
116 let candidates = self
117 .db
118 .prepare(CANDIDATES)
119 .bind(&[repo_id.into(), base, landed_pull.map_or(JsValue::NULL, Into::into), MAX_CANDIDATES.into()])?
120 .all()
121 .await?
122 .results::<CandidateRow>()?;
123 let closes_issues = default && self.settings(repo_id).await?.auto_close_issues;
124 if candidates.is_empty() && !closes_issues {
125 return Ok(Vec::new());
126 }
127 let pushed: Pushed = g1t_kit::call(
128 &self.repos,
129 "pushed",
130 &PushedArgs {
131 repo_id: repo_id.to_owned(),
132 before: event.data["before"].as_str().map(str::to_owned),
133 after: after.to_owned(),
134 heads: candidates.iter().map(|row| row.head_commit.clone()).collect(),
135 messages: closes_issues,
136 // No `before` on the default branch: the repository's first push.
137 first: default && event.data["before"].as_str().is_none(),
138 },
139 )
140 .await?;
141 if pushed.landed.is_empty() && pushed.commits.is_empty() {
142 return Ok(Vec::new());
143 }
144 let actor: Viewer =
145 g1t_kit::call(&self.identity, "user_by_id", &g1t_contracts::identity::UserIdArgs { id: actor_id.to_owned() }).await?;
146 let Some(actor) = actor else { return Ok(Vec::new()) };
147 let repo: Outcome<Repo> =
148 g1t_kit::call(&self.repos, "get_by_id", &GetByIdArgs { id: repo_id.to_owned(), viewer: Some(actor.clone()) }).await?;
149 let Outcome::Ok(repo) = repo else { return Ok(Vec::new()) };
150
151 let mut merged = Vec::new();
152 for row in candidates.iter().filter(|row| pushed.landed.contains(&row.head_commit)) {
153 let Some(pull) = self.pull_by_id(&row.id).await? else { continue };
154 let landed = Landed {
155 commit: after.to_owned(),
156 previous: event.data["before"].as_str().map(str::to_owned),
157 method: if row.head_commit == after { MergeMethod::FastForward } else { MergeMethod::Merge },
158 rebased: Vec::new(),
159 };
160 let recorded = self.record_merge(&repo, pull, &actor, false, landed, Some(branch)).await?;
161 if recorded.status != PullStatus::Merged {
162 continue;
163 }
164 // Waiting in the merge queue: it has nothing left to land.
165 if self.leave(repo_id, &recorded, QueueState::Removed, Some("A push merged it.")).await? {
166 self.publish_as(
167 "queue.changed",
168 repo_id,
169 None,
170 g1t_contracts::events::QueueChanged { repo_id: repo_id.to_owned() },
171 )
172 .await?;
173 }
174 merged.push(recorded.id);
175 }
176
177 if closes_issues {
178 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
179 for (number, commit) in commit_closes(&pushed.commits, &path) {
180 let message = pushed.commits.iter().find(|pushed| pushed.hash == commit).map(|pushed| pushed.message.as_str());
181 let session = self.trailer_session(&repo.id, message.unwrap_or_default()).await?;
182 self.close_by_commit(&repo, number, &commit, &actor, session.as_deref()).await?;
183 }
184 }
185 Ok(merged)
186 }
187
188 /// Closes an open issue as completed by a commit pushed to the default
189 /// branch: says so on the issue, with the session it was closed in when
190 /// one is known, and lets go of what waited on it. Whether it closed it:
191 /// not when it was closed already.
192 pub(crate) async fn close_by_commit(
193 &self,
194 repo: &Repo,
195 number: u32,
196 commit: &str,
197 actor: &User,
198 session: Option<&str>,
199 ) -> Result<bool> {
200 let Some(issue) = self.issue(&repo.id, number).await?.filter(|issue| issue.state == State::Open) else {
201 return Ok(false);
202 };
203 let now = rfc3339(now_ms());
204 let closed = self
205 .db
206 .prepare(CLOSE_BY_COMMIT)
207 .bind(&[
208 commit.into(),
209 actor.username.as_str().into(),
210 now.as_str().into(),
211 now.as_str().into(),
212 issue.id.as_str().into(),
213 ])?
214 .first::<crate::rows::ValueRow>(None)
215 .await?;
216 if closed.is_none() {
217 return Ok(false);
218 }
219 self.publish(
220 "issue.closed",
221 &repo.id,
222 actor,
223 IssueEvent {
224 reason: Some(IssueReason::Completed.as_str()),
225 commit: Some(commit.to_owned()),
226 session: session.map(str::to_owned),
227 ..Self::issue_event(&issue)
228 },
229 )
230 .await?;
231 self.note(&repo.id, number, (&actor.id, &actor.username), &closed_note(&format!("closed this in {commit}"), session))
232 .await?;
233 self.issue_resolved(&repo.id, number, actor, true, None).await;
234 Ok(true)
235 }
236
237 /// `close_issue` with a commit: the commit, on the default branch of
238 /// `repo`, whose message names issue `number` after a closing keyword,
239 /// with the session it names, if it names one of this repository's.
240 /// Anything else is refused, saying why.
241 pub(crate) async fn closing_commit(
242 &self,
243 repo: &Repo,
244 number: u32,
245 commit: &str,
246 actor: &User,
247 ) -> Result<Outcome<(String, Option<String>)>> {
248 let commit = commit.trim().to_lowercase();
249 if !(4..=64).contains(&commit.len()) || !commit.bytes().all(|b| b.is_ascii_hexdigit()) {
250 return Ok(Outcome::fail(FailureCode::Invalid, "commit is a commit's hash."));
251 }
252 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
253 let log = |at: Option<String>| LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: at, limit: 1 };
254 let (named, tip) = (log(Some(commit.clone())), log(None));
255 let (found, head) = futures_util::future::join(
256 g1t_kit::call::<_, Outcome<Vec<Commit>>>(&self.repos, "log", &named),
257 g1t_kit::call::<_, Outcome<Vec<Commit>>>(&self.repos, "log", &tip),
258 )
259 .await;
260 let found = match found? {
261 Outcome::Ok(commits) => commits.into_iter().next().filter(|found| found.hash.starts_with(&commit)),
262 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
263 };
264 let Some(found) = found else {
265 return Ok(Outcome::fail(
266 FailureCode::NotFound,
267 format!("There is no commit {commit} in {}/{}; give its full hash.", repo.namespace, repo.name),
268 ));
269 };
270 let short = &found.hash[..7.min(found.hash.len())];
271 if !closing::references(&found.message, &path).contains(&number) {
272 return Ok(Outcome::fail(
273 FailureCode::Invalid,
274 format!("Commit {short} does not close #{number}: its message names it after no closing keyword (Fixes #{number})."),
275 ));
276 }
277 let head = match head? {
278 Outcome::Ok(commits) => commits.into_iter().next(),
279 Outcome::Fail(_) => None,
280 };
281 let Some(head) = head else {
282 return Ok(Outcome::fail(FailureCode::Invalid, format!("Commit {short} is not on {}.", repo.default_branch)));
283 };
284 if head.hash != found.hash {
285 let drifts: Outcome<BranchDrifts> = g1t_kit::call(
286 &self.repos,
287 "branch_drift",
288 &BranchDriftArgs { path: path.clone(), viewer: Some(actor.clone()), base: head.hash, heads: vec![found.hash.clone()] },
289 )
290 .await?;
291 let drift = match drifts {
292 Outcome::Ok(drifts) => drifts.branches.into_iter().next().and_then(|branch| branch.drift),
293 Outcome::Fail(_) => None,
294 };
295 match drift {
296 Some(drift) if drift.ahead == 0 => {}
297 Some(_) => {
298 return Ok(Outcome::fail(
299 FailureCode::Invalid,
300 format!("Commit {short} is not on {}, the default branch: only a commit there closes an issue.", repo.default_branch),
301 ));
302 }
303 None => {
304 return Ok(Outcome::fail(
305 FailureCode::Invalid,
306 format!("Could not tell whether commit {short} is on {}: it is too far back.", repo.default_branch),
307 ));
308 }
309 }
310 }
311 let session = self.trailer_session(&repo.id, &found.message).await?;
312 Ok(Outcome::Ok((found.hash, session)))
313 }
314
315 /// The session a commit message names in a `G1t-Session` trailer, when
316 /// it is one of this repository's.
317 pub(crate) async fn trailer_session(&self, repo_id: &str, message: &str) -> Result<Option<String>> {
318 let Some(id) = session_trailers(message).into_iter().next() else { return Ok(None) };
319 Ok(self
320 .db
321 .prepare(SESSION_IN_REPO)
322 .bind(&[id.as_str().into(), repo_id.into()])?
323 .first::<crate::rows::ValueRow>(None)
324 .await?
325 .map(|row| row.value))
326 }
327
328 /// The session closing an issue with a token: the caller's own session
329 /// on the issue that is still going, the one seen last. A person
330 /// signed in on the site closes it in none.
331 pub(crate) async fn caller_session(&self, repo_id: &str, number: u32, actor: &User) -> Result<Option<String>> {
332 if actor.token.is_none() && actor.acting.is_none() {
333 return Ok(None);
334 }
335 let person = actor.acting.as_ref().map_or(actor.id.as_str(), |acting| acting.on_behalf_of.id.as_str());
336 Ok(self
337 .db
338 .prepare(CALLER_SESSION)
339 .bind(&[repo_id.into(), number.into(), actor.id.as_str().into(), person.into()])?
340 .first::<crate::rows::ValueRow>(None)
341 .await?
342 .map(|row| row.value))
343 }
344
345 /// `closed_by_commit`: the issues a commit closed (`close_by_commit`),
346 /// for anyone who may read the repository.
347 pub(crate) async fn closed_by_commit(&self, a: ClosedByCommitArgs) -> Result<Outcome<Vec<ClosedIssue>>> {
348 let Some(commit) = crate::commit_comments::valid_commit(&a.commit).filter(|commit| commit.len() >= 40) else {
349 return Ok(Outcome::Ok(Vec::new()));
350 };
351 let repo = match self.repo(&a.repo, &a.viewer).await? {
352 Outcome::Ok(repo) => repo,
353 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
354 };
355 Ok(Outcome::Ok(
356 self.db
357 .prepare(CLOSED_BY_COMMIT)
358 .bind(&[repo.id.as_str().into(), commit.into()])?
359 .all()
360 .await?
361 .results::<ClosedIssue>()?,
362 ))
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use g1t_contracts::repos::PushedCommit;
369 use rusqlite::{Connection, OptionalExtension, params};
370
371 use super::*;
372
373 /// The work database as the migrations make it.
374 fn database() -> Connection {
375 let db = Connection::open_in_memory().unwrap();
376 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
377 let mut paths: Vec<_> = std::fs::read_dir(dir)
378 .unwrap()
379 .map(|entry| entry.unwrap().path())
380 .filter(|path| path.extension().is_some_and(|ext| ext == "sql"))
381 .collect();
382 paths.sort();
383 for path in paths {
384 db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
385 }
386 db
387 }
388
389 fn pull(db: &Connection, id: &str, number: u32, status: &str, base: Option<&str>, head: Option<&str>) {
390 db.execute(
391 "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, status,
392 base_branch, head_commit, created_at, updated_at)
393 VALUES (?1, 'repo', ?2, 'Change', 'codex', 'external', 'user', 'user', ?3, ?4, ?5, 'now', 'now')",
394 params![id, number, status, base, head],
395 )
396 .unwrap();
397 }
398
399 fn candidates(db: &Connection, base: Option<&str>, landed: Option<u32>) -> Vec<String> {
400 let mut statement = db.prepare(CANDIDATES).unwrap();
401 let mut ids: Vec<String> = statement
402 .query_map(params!["repo", base, landed, MAX_CANDIDATES], |row| row.get(0))
403 .unwrap()
404 .map(Result::unwrap)
405 .collect();
406 ids.sort();
407 ids
408 }
409
410 #[test]
411 fn the_open_pull_requests_into_the_branch_are_looked_at() {
412 let db = database();
413 pull(&db, "open", 1, "open", None, Some("h1"));
414 pull(&db, "draft", 2, "draft", None, Some("h2"));
415 pull(&db, "closed", 3, "closed", None, Some("h3"));
416 pull(&db, "merged", 4, "merged", None, Some("h4"));
417 pull(&db, "empty", 5, "open", None, None);
418 pull(&db, "release", 6, "open", Some("release"), Some("h6"));
419 assert_eq!(candidates(&db, None, None), vec!["draft", "open"]);
420 assert_eq!(candidates(&db, Some("release"), None), vec!["release"]);
421 // The pull request the push landed is recorded by whoever landed it.
422 assert_eq!(candidates(&db, None, Some(1)), vec!["draft"]);
423 }
424
425 #[test]
426 fn the_entry_the_merge_queue_is_landing_is_left_to_it() {
427 let db = database();
428 pull(&db, "landing", 1, "open", None, Some("h1"));
429 pull(&db, "waiting", 2, "open", None, Some("h2"));
430 db.execute_batch(
431 "INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at, landing_commit)
432 VALUES ('e1', 'passed', 'repo', 'landing', 1, '{}', 'now', 'c1');
433 INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at)
434 VALUES ('e2', 'waiting', 'repo', 'waiting', 2, '{}', 'now');",
435 )
436 .unwrap();
437 assert_eq!(candidates(&db, None, None), vec!["waiting"]);
438 }
439
440 #[test]
441 fn a_commit_closes_an_open_issue_once_and_says_which() {
442 let db = database();
443 db.execute_batch(
444 "INSERT INTO issues (id, repo_id, number, title, body, labels, state, author_id, author_name, created_at, updated_at)
445 VALUES ('iss', 'repo', 7, 'Broken header', '', '[]', 'open', 'user', 'user', 'now', 'now');",
446 )
447 .unwrap();
448 let close = |commit: &str| {
449 db.query_row(CLOSE_BY_COMMIT, params![commit, "ana", "later", "later", "iss"], |row| row.get::<_, String>(0))
450 .optional()
451 .unwrap()
452 };
453 assert_eq!(close("c1").as_deref(), Some("iss"));
454 // Already closed: a later commit naming it changes nothing.
455 assert_eq!(close("c2"), None);
456 let (state, commit, by, resolved_by): (String, String, String, Option<u32>) = db
457 .query_row("SELECT state, resolved_commit, resolved_pushed_by, resolved_by FROM issues WHERE id = 'iss'", [], |row| {
458 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
459 })
460 .unwrap();
461 assert_eq!((state.as_str(), commit.as_str(), by.as_str(), resolved_by), ("closed", "c1", "ana", None));
462 let closed: Vec<(u32, String)> = db
463 .prepare(CLOSED_BY_COMMIT)
464 .unwrap()
465 .query_map(params!["repo", "c1"], |row| Ok((row.get(0)?, row.get(1)?)))
466 .unwrap()
467 .map(Result::unwrap)
468 .collect();
469 assert_eq!(closed, vec![(7, "Broken header".to_owned())]);
470 }
471
472 fn commit(hash: &str, message: &str) -> PushedCommit {
473 PushedCommit { hash: hash.into(), message: message.into() }
474 }
475
476 #[test]
477 fn each_issue_is_closed_by_the_first_commit_that_names_it() {
478 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
479 let commits = [
480 commit("a", "Fix the header\n\nFixes #12, closes #3"),
481 commit("b", "Tidy up (#12)\n\nResolves #12 and resolves acme/web#7"),
482 commit("c", "Mention #9 without a keyword; see `fixes #10` in code"),
483 commit("d", "Closes other/repo#4"),
484 ];
485 assert_eq!(commit_closes(&commits, &repo), vec![(12, "a".into()), (3, "a".into()), (7, "b".into())]);
486 }
487
488 #[test]
489 fn no_more_than_a_pull_request_closes() {
490 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
491 let message: String = (1..=80).map(|n| format!("Fixes #{n}\n")).collect();
492 assert_eq!(commit_closes(&[commit("a", &message)], &repo).len(), closing::MAX_CLOSES);
493 }
494
495 #[test]
496 fn a_close_names_the_session_it_was_made_in() {
497 assert_eq!(closed_note("closed this in abc", Some("ses_1")), "closed this in abc via session ses_1");
498 assert_eq!(closed_note("closed this as completed", None), "closed this as completed");
499 }
500
501 #[test]
502 fn the_callers_session_on_the_issue_is_the_one_still_going_seen_last() {
503 let db = database();
504 let session = |id: &str, actor: &str, issue: Option<u32>, state: &str, seen: &str| {
505 db.execute(
506 "INSERT INTO sessions (id, repo_id, issue, actor_id, actor_name, harness, state, started_at, last_seen_at)
507 VALUES (?1, 'repo', ?2, ?3, ?3, 'claude_code', ?4, 'then', ?5)",
508 params![id, issue, actor, state, seen],
509 )
510 .unwrap();
511 };
512 session("ses_old", "ana", Some(7), "active", "1");
513 session("ses_new", "ana", Some(7), "active", "2");
514 session("ses_ended", "ana", Some(7), "ended", "3");
515 session("ses_other", "bo", Some(7), "active", "4");
516 session("ses_elsewhere", "ana", Some(8), "active", "5");
517 // Tied to issue 9 since it started, by a claim.
518 session("ses_claimed", "ana", None, "active", "6");
519 db.execute(
520 "INSERT INTO session_links (session_id, repo_id, kind, number, via, at) VALUES ('ses_claimed', 'repo', 'issue', 9, 'claim', 'now')",
521 [],
522 )
523 .unwrap();
524 let caller = |number: u32, actor: &str, person: &str| -> Option<String> {
525 db.query_row(CALLER_SESSION, params!["repo", number, actor, person], |row| row.get(0)).optional().unwrap()
526 };
527 assert_eq!(caller(7, "ana", "ana").as_deref(), Some("ses_new"));
528 // g1t acting for ana: ana's session.
529 assert_eq!(caller(7, "g1t", "ana").as_deref(), Some("ses_new"));
530 assert_eq!(caller(9, "ana", "ana").as_deref(), Some("ses_claimed"));
531 assert_eq!(caller(7, "cy", "cy"), None);
532 let in_repo = |id: &str| -> Option<String> {
533 db.query_row(SESSION_IN_REPO, params![id, "repo"], |row| row.get(0)).optional().unwrap()
534 };
535 assert_eq!(in_repo("ses_old").as_deref(), Some("ses_old"));
536 assert_eq!(in_repo("ses_nowhere"), None);
537 }
538}