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
549 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 // A session g1t started pushed it: what it closes is g1t's, for the pusher.
178 let agent = event.data["agent"].as_str().is_some();
179 if closes_issues {
180 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
181 for (number, commit) in commit_closes(&pushed.commits, &path) {
182 let message = pushed.commits.iter().find(|pushed| pushed.hash == commit).map(|pushed| pushed.message.as_str());
183 let session = self.trailer_session(&repo.id, message.unwrap_or_default()).await?;
184 self.close_by_commit(&repo, number, &commit, &actor, session.as_deref(), agent).await?;
185 }
186 }
187 Ok(merged)
188 }
189
190 /// Closes an open issue as completed by a commit pushed to the default
191 /// branch: says so on the issue, with the session it was closed in when
192 /// one is known, and lets go of what waited on it. `agent` when an agent
193 /// pushed it for `actor` (GitPush::agent): the note is then g1t's, for
194 /// them. Whether it closed it: not when it was closed already.
195 pub(crate) async fn close_by_commit(
196 &self,
197 repo: &Repo,
198 number: u32,
199 commit: &str,
200 actor: &User,
201 session: Option<&str>,
202 agent: bool,
203 ) -> Result<bool> {
204 let Some(issue) = self.issue(&repo.id, number).await?.filter(|issue| issue.state == State::Open) else {
205 return Ok(false);
206 };
207 let now = rfc3339(now_ms());
208 let closed = self
209 .db
210 .prepare(CLOSE_BY_COMMIT)
211 .bind(&[
212 commit.into(),
213 actor.username.as_str().into(),
214 now.as_str().into(),
215 now.as_str().into(),
216 issue.id.as_str().into(),
217 ])?
218 .first::<crate::rows::ValueRow>(None)
219 .await?;
220 if closed.is_none() {
221 return Ok(false);
222 }
223 self.publish(
224 "issue.closed",
225 &repo.id,
226 actor,
227 IssueEvent {
228 reason: Some(IssueReason::Completed.as_str()),
229 commit: Some(commit.to_owned()),
230 session: session.map(str::to_owned),
231 ..Self::issue_event(&issue)
232 },
233 )
234 .await?;
235 let by = match crate::rows::By::from(actor) {
236 by if agent && by.acting_for.is_none() && by.id == actor.id => crate::rows::By {
237 id: g1t_contracts::identity::AGENT_ID,
238 name: g1t_contracts::identity::AGENT_NAME,
239 acting_for: Some((&actor.id, &actor.username)),
240 },
241 by => by,
242 };
243 self.note(&repo.id, number, by, &closed_note(&format!("closed this in {commit}"), session)).await?;
244 self.issue_resolved(&repo.id, number, actor, true, None).await;
245 Ok(true)
246 }
247
248 /// `close_issue` with a commit: the commit, on the default branch of
249 /// `repo`, whose message names issue `number` after a closing keyword,
250 /// with the session it names, if it names one of this repository's.
251 /// Anything else is refused, saying why.
252 pub(crate) async fn closing_commit(
253 &self,
254 repo: &Repo,
255 number: u32,
256 commit: &str,
257 actor: &User,
258 ) -> Result<Outcome<(String, Option<String>)>> {
259 let commit = commit.trim().to_lowercase();
260 if !(4..=64).contains(&commit.len()) || !commit.bytes().all(|b| b.is_ascii_hexdigit()) {
261 return Ok(Outcome::fail(FailureCode::Invalid, "commit is a commit's hash."));
262 }
263 let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
264 let log = |at: Option<String>| LogArgs { path: path.clone(), viewer: Some(actor.clone()), git_ref: at, limit: 1 };
265 let (named, tip) = (log(Some(commit.clone())), log(None));
266 let (found, head) = futures_util::future::join(
267 g1t_kit::call::<_, Outcome<Vec<Commit>>>(&self.repos, "log", &named),
268 g1t_kit::call::<_, Outcome<Vec<Commit>>>(&self.repos, "log", &tip),
269 )
270 .await;
271 let found = match found? {
272 Outcome::Ok(commits) => commits.into_iter().next().filter(|found| found.hash.starts_with(&commit)),
273 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
274 };
275 let Some(found) = found else {
276 return Ok(Outcome::fail(
277 FailureCode::NotFound,
278 format!("There is no commit {commit} in {}/{}; give its full hash.", repo.namespace, repo.name),
279 ));
280 };
281 let short = &found.hash[..7.min(found.hash.len())];
282 if !closing::references(&found.message, &path).contains(&number) {
283 return Ok(Outcome::fail(
284 FailureCode::Invalid,
285 format!("Commit {short} does not close #{number}: its message names it after no closing keyword (Fixes #{number})."),
286 ));
287 }
288 let head = match head? {
289 Outcome::Ok(commits) => commits.into_iter().next(),
290 Outcome::Fail(_) => None,
291 };
292 let Some(head) = head else {
293 return Ok(Outcome::fail(FailureCode::Invalid, format!("Commit {short} is not on {}.", repo.default_branch)));
294 };
295 if head.hash != found.hash {
296 let drifts: Outcome<BranchDrifts> = g1t_kit::call(
297 &self.repos,
298 "branch_drift",
299 &BranchDriftArgs { path: path.clone(), viewer: Some(actor.clone()), base: head.hash, heads: vec![found.hash.clone()] },
300 )
301 .await?;
302 let drift = match drifts {
303 Outcome::Ok(drifts) => drifts.branches.into_iter().next().and_then(|branch| branch.drift),
304 Outcome::Fail(_) => None,
305 };
306 match drift {
307 Some(drift) if drift.ahead == 0 => {}
308 Some(_) => {
309 return Ok(Outcome::fail(
310 FailureCode::Invalid,
311 format!("Commit {short} is not on {}, the default branch: only a commit there closes an issue.", repo.default_branch),
312 ));
313 }
314 None => {
315 return Ok(Outcome::fail(
316 FailureCode::Invalid,
317 format!("Could not tell whether commit {short} is on {}: it is too far back.", repo.default_branch),
318 ));
319 }
320 }
321 }
322 let session = self.trailer_session(&repo.id, &found.message).await?;
323 Ok(Outcome::Ok((found.hash, session)))
324 }
325
326 /// The session a commit message names in a `G1t-Session` trailer, when
327 /// it is one of this repository's.
328 pub(crate) async fn trailer_session(&self, repo_id: &str, message: &str) -> Result<Option<String>> {
329 let Some(id) = session_trailers(message).into_iter().next() else { return Ok(None) };
330 Ok(self
331 .db
332 .prepare(SESSION_IN_REPO)
333 .bind(&[id.as_str().into(), repo_id.into()])?
334 .first::<crate::rows::ValueRow>(None)
335 .await?
336 .map(|row| row.value))
337 }
338
339 /// The session closing an issue with a token: the caller's own session
340 /// on the issue that is still going, the one seen last. A person
341 /// signed in on the site closes it in none.
342 pub(crate) async fn caller_session(&self, repo_id: &str, number: u32, actor: &User) -> Result<Option<String>> {
343 if actor.token.is_none() && actor.acting.is_none() {
344 return Ok(None);
345 }
346 let person = actor.acting.as_ref().map_or(actor.id.as_str(), |acting| acting.on_behalf_of.id.as_str());
347 Ok(self
348 .db
349 .prepare(CALLER_SESSION)
350 .bind(&[repo_id.into(), number.into(), actor.id.as_str().into(), person.into()])?
351 .first::<crate::rows::ValueRow>(None)
352 .await?
353 .map(|row| row.value))
354 }
355
356 /// `closed_by_commit`: the issues a commit closed (`close_by_commit`),
357 /// for anyone who may read the repository.
358 pub(crate) async fn closed_by_commit(&self, a: ClosedByCommitArgs) -> Result<Outcome<Vec<ClosedIssue>>> {
359 let Some(commit) = crate::commit_comments::valid_commit(&a.commit).filter(|commit| commit.len() >= 40) else {
360 return Ok(Outcome::Ok(Vec::new()));
361 };
362 let repo = match self.repo(&a.repo, &a.viewer).await? {
363 Outcome::Ok(repo) => repo,
364 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
365 };
366 Ok(Outcome::Ok(
367 self.db
368 .prepare(CLOSED_BY_COMMIT)
369 .bind(&[repo.id.as_str().into(), commit.into()])?
370 .all()
371 .await?
372 .results::<ClosedIssue>()?,
373 ))
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use g1t_contracts::repos::PushedCommit;
380 use rusqlite::{Connection, OptionalExtension, params};
381
382 use super::*;
383
384 /// The work database as the migrations make it.
385 fn database() -> Connection {
386 let db = Connection::open_in_memory().unwrap();
387 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations");
388 let mut paths: Vec<_> = std::fs::read_dir(dir)
389 .unwrap()
390 .map(|entry| entry.unwrap().path())
391 .filter(|path| path.extension().is_some_and(|ext| ext == "sql"))
392 .collect();
393 paths.sort();
394 for path in paths {
395 db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
396 }
397 db
398 }
399
400 fn pull(db: &Connection, id: &str, number: u32, status: &str, base: Option<&str>, head: Option<&str>) {
401 db.execute(
402 "INSERT INTO pulls (id, repo_id, number, title, agent, runtime, author_id, author_name, status,
403 base_branch, head_commit, created_at, updated_at)
404 VALUES (?1, 'repo', ?2, 'Change', 'codex', 'external', 'user', 'user', ?3, ?4, ?5, 'now', 'now')",
405 params![id, number, status, base, head],
406 )
407 .unwrap();
408 }
409
410 fn candidates(db: &Connection, base: Option<&str>, landed: Option<u32>) -> Vec<String> {
411 let mut statement = db.prepare(CANDIDATES).unwrap();
412 let mut ids: Vec<String> = statement
413 .query_map(params!["repo", base, landed, MAX_CANDIDATES], |row| row.get(0))
414 .unwrap()
415 .map(Result::unwrap)
416 .collect();
417 ids.sort();
418 ids
419 }
420
421 #[test]
422 fn the_open_pull_requests_into_the_branch_are_looked_at() {
423 let db = database();
424 pull(&db, "open", 1, "open", None, Some("h1"));
425 pull(&db, "draft", 2, "draft", None, Some("h2"));
426 pull(&db, "closed", 3, "closed", None, Some("h3"));
427 pull(&db, "merged", 4, "merged", None, Some("h4"));
428 pull(&db, "empty", 5, "open", None, None);
429 pull(&db, "release", 6, "open", Some("release"), Some("h6"));
430 assert_eq!(candidates(&db, None, None), vec!["draft", "open"]);
431 assert_eq!(candidates(&db, Some("release"), None), vec!["release"]);
432 // The pull request the push landed is recorded by whoever landed it.
433 assert_eq!(candidates(&db, None, Some(1)), vec!["draft"]);
434 }
435
436 #[test]
437 fn the_entry_the_merge_queue_is_landing_is_left_to_it() {
438 let db = database();
439 pull(&db, "landing", 1, "open", None, Some("h1"));
440 pull(&db, "waiting", 2, "open", None, Some("h2"));
441 db.execute_batch(
442 "INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at, landing_commit)
443 VALUES ('e1', 'passed', 'repo', 'landing', 1, '{}', 'now', 'c1');
444 INSERT INTO queue_entries (id, state, repo_id, pull_id, number, enqueued_by, created_at)
445 VALUES ('e2', 'waiting', 'repo', 'waiting', 2, '{}', 'now');",
446 )
447 .unwrap();
448 assert_eq!(candidates(&db, None, None), vec!["waiting"]);
449 }
450
451 #[test]
452 fn a_commit_closes_an_open_issue_once_and_says_which() {
453 let db = database();
454 db.execute_batch(
455 "INSERT INTO issues (id, repo_id, number, title, body, labels, state, author_id, author_name, created_at, updated_at)
456 VALUES ('iss', 'repo', 7, 'Broken header', '', '[]', 'open', 'user', 'user', 'now', 'now');",
457 )
458 .unwrap();
459 let close = |commit: &str| {
460 db.query_row(CLOSE_BY_COMMIT, params![commit, "ana", "later", "later", "iss"], |row| row.get::<_, String>(0))
461 .optional()
462 .unwrap()
463 };
464 assert_eq!(close("c1").as_deref(), Some("iss"));
465 // Already closed: a later commit naming it changes nothing.
466 assert_eq!(close("c2"), None);
467 let (state, commit, by, resolved_by): (String, String, String, Option<u32>) = db
468 .query_row("SELECT state, resolved_commit, resolved_pushed_by, resolved_by FROM issues WHERE id = 'iss'", [], |row| {
469 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
470 })
471 .unwrap();
472 assert_eq!((state.as_str(), commit.as_str(), by.as_str(), resolved_by), ("closed", "c1", "ana", None));
473 let closed: Vec<(u32, String)> = db
474 .prepare(CLOSED_BY_COMMIT)
475 .unwrap()
476 .query_map(params!["repo", "c1"], |row| Ok((row.get(0)?, row.get(1)?)))
477 .unwrap()
478 .map(Result::unwrap)
479 .collect();
480 assert_eq!(closed, vec![(7, "Broken header".to_owned())]);
481 }
482
483 fn commit(hash: &str, message: &str) -> PushedCommit {
484 PushedCommit { hash: hash.into(), message: message.into() }
485 }
486
487 #[test]
488 fn each_issue_is_closed_by_the_first_commit_that_names_it() {
489 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
490 let commits = [
491 commit("a", "Fix the header\n\nFixes #12, closes #3"),
492 commit("b", "Tidy up (#12)\n\nResolves #12 and resolves acme/web#7"),
493 commit("c", "Mention #9 without a keyword; see `fixes #10` in code"),
494 commit("d", "Closes other/repo#4"),
495 ];
496 assert_eq!(commit_closes(&commits, &repo), vec![(12, "a".into()), (3, "a".into()), (7, "b".into())]);
497 }
498
499 #[test]
500 fn no_more_than_a_pull_request_closes() {
501 let repo = RepoPath { namespace: "acme".into(), name: "web".into() };
502 let message: String = (1..=80).map(|n| format!("Fixes #{n}\n")).collect();
503 assert_eq!(commit_closes(&[commit("a", &message)], &repo).len(), closing::MAX_CLOSES);
504 }
505
506 #[test]
507 fn a_close_names_the_session_it_was_made_in() {
508 assert_eq!(closed_note("closed this in abc", Some("ses_1")), "closed this in abc via session ses_1");
509 assert_eq!(closed_note("closed this as completed", None), "closed this as completed");
510 }
511
512 #[test]
513 fn the_callers_session_on_the_issue_is_the_one_still_going_seen_last() {
514 let db = database();
515 let session = |id: &str, actor: &str, issue: Option<u32>, state: &str, seen: &str| {
516 db.execute(
517 "INSERT INTO sessions (id, repo_id, issue, actor_id, actor_name, harness, state, started_at, last_seen_at)
518 VALUES (?1, 'repo', ?2, ?3, ?3, 'claude_code', ?4, 'then', ?5)",
519 params![id, issue, actor, state, seen],
520 )
521 .unwrap();
522 };
523 session("ses_old", "ana", Some(7), "active", "1");
524 session("ses_new", "ana", Some(7), "active", "2");
525 session("ses_ended", "ana", Some(7), "ended", "3");
526 session("ses_other", "bo", Some(7), "active", "4");
527 session("ses_elsewhere", "ana", Some(8), "active", "5");
528 // Tied to issue 9 since it started, by a claim.
529 session("ses_claimed", "ana", None, "active", "6");
530 db.execute(
531 "INSERT INTO session_links (session_id, repo_id, kind, number, via, at) VALUES ('ses_claimed', 'repo', 'issue', 9, 'claim', 'now')",
532 [],
533 )
534 .unwrap();
535 let caller = |number: u32, actor: &str, person: &str| -> Option<String> {
536 db.query_row(CALLER_SESSION, params!["repo", number, actor, person], |row| row.get(0)).optional().unwrap()
537 };
538 assert_eq!(caller(7, "ana", "ana").as_deref(), Some("ses_new"));
539 // g1t acting for ana: ana's session.
540 assert_eq!(caller(7, "g1t", "ana").as_deref(), Some("ses_new"));
541 assert_eq!(caller(9, "ana", "ana").as_deref(), Some("ses_claimed"));
542 assert_eq!(caller(7, "cy", "cy"), None);
543 let in_repo = |id: &str| -> Option<String> {
544 db.query_row(SESSION_IN_REPO, params![id, "repo"], |row| row.get(0)).optional().unwrap()
545 };
546 assert_eq!(in_repo("ses_old").as_deref(), Some("ses_old"));
547 assert_eq!(in_repo("ses_nowhere"), None);
548 }
549}