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
791 linesCodeBlameRaw
1//! Coordinating work between agents and people: sessions, claims and
2//! dependencies.
3//!
4//! The issue is the unit of work. A **session** is one run of someone's
5//! own harness (a Claude Code session, a Codex run) on a repository, by a
6//! person's token, optionally attached to an issue, a pull request and a
7//! branch. A **claim** is an advisory, expiring "I'm working on #N": never
8//! a lock, so two can stand on one issue, and the second claimant is told
9//! about the first. An issue's `blocked_by` names the issues that have to
10//! land first.
11//!
12//! Hooks and telemetry are evidence, never truth: what git and g1t record
13//! is. So sessions tolerate missed heartbeats, reconnects and duplicate
14//! events: starting one is idempotent on the harness's own session id,
15//! entries are idempotent on their `key`, and a session or claim that
16//! went quiet is read as idle, ended or expired when it is next looked at.
17
18use serde::{Deserialize, Serialize};
19
20use super::{CheckStatus, Issue, Mergeable, NewSessionEntry, Overlap, PullStatus, QueueState, RequiredCheck, SessionEntry, State};
21use crate::credentials::Principal;
22use crate::events::Event;
23use crate::repos::RepoPath;
24use crate::{User, Viewer};
25
26/// A session with no heartbeat for this long reads as idle.
27pub const SESSION_IDLE_MS: u64 = 15 * 60 * 1000;
28/// A session with no heartbeat for this long has ended (`timed_out`).
29pub const SESSION_TIMEOUT_MS: u64 = 2 * 60 * 60 * 1000;
30/// How long a claim lasts unless renewed, in minutes.
31pub const CLAIM_DEFAULT_MINUTES: u32 = 30;
32/// The longest a claim may be asked to last, in minutes.
33pub const CLAIM_MAX_MINUTES: u32 = 240;
34/// The most issues one issue can be blocked by.
35pub const MAX_BLOCKED_BY: usize = 50;
36/// The most characters a session's title, a claim's note or a reason keeps.
37pub const MAX_NOTE_CHARS: usize = 500;
38/// The most characters an entry's idempotency key may have.
39pub const MAX_KEY_CHARS: usize = 200;
40
41/// The event types about coordinating work, which `get_work` lists as an
42/// issue's recent activity (with the merge queue's, once it sends them).
43pub const COORDINATION_EVENTS: [&str; 11] = [
44 "session.started",
45 "session.ended",
46 "work.claimed",
47 "work.released",
48 "work.claim_expired",
49 "work.handoff_requested",
50 "dependency.changed",
51 "overlap.detected",
52 "integration.failed",
53 "integration.passed",
54 "decision.recorded",
55];
56
57/// The harness a session runs in.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Harness {
61 ClaudeCode,
62 Codex,
63 Cursor,
64 Copilot,
65 Other,
66}
67
68impl Harness {
69 pub const ALL: [Harness; 5] = [Harness::ClaudeCode, Harness::Codex, Harness::Cursor, Harness::Copilot, Harness::Other];
70
71 pub fn as_str(self) -> &'static str {
72 match self {
73 Harness::ClaudeCode => "claude_code",
74 Harness::Codex => "codex",
75 Harness::Cursor => "cursor",
76 Harness::Copilot => "copilot",
77 Harness::Other => "other",
78 }
79 }
80
81 /// From its name, in either case, with `-` for `_`.
82 pub fn parse(name: &str) -> Option<Harness> {
83 let name = name.trim().to_ascii_lowercase().replace('-', "_");
84 Harness::ALL.into_iter().find(|harness| harness.as_str() == name)
85 }
86}
87
88/// Where a session stands.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum SessionState {
92 /// Heard from in the last 15 minutes.
93 Active,
94 /// Not heard from for 15 minutes; it may still come back.
95 Idle,
96 /// Ended, or not heard from for 2 hours (`end_reason` `timed_out`).
97 Ended,
98}
99
100impl SessionState {
101 pub fn as_str(self) -> &'static str {
102 match self {
103 SessionState::Active => "active",
104 SessionState::Idle => "idle",
105 SessionState::Ended => "ended",
106 }
107 }
108
109 pub fn parse(name: &str) -> Option<SessionState> {
110 match name.trim().to_ascii_lowercase().as_str() {
111 "active" => Some(SessionState::Active),
112 "idle" => Some(SessionState::Idle),
113 "ended" => Some(SessionState::Ended),
114 _ => None,
115 }
116 }
117
118 /// A session as it reads at `now`: `ended` when it was ended or has
119 /// been quiet for [`SESSION_TIMEOUT_MS`], `idle` after
120 /// [`SESSION_IDLE_MS`], else `active`.
121 pub fn at(ended: bool, last_seen_ms: u64, now_ms: u64) -> SessionState {
122 let quiet = now_ms.saturating_sub(last_seen_ms);
123 if ended || quiet >= SESSION_TIMEOUT_MS {
124 SessionState::Ended
125 } else if quiet >= SESSION_IDLE_MS {
126 SessionState::Idle
127 } else {
128 SessionState::Active
129 }
130 }
131}
132
133/// One run of someone's own harness on a repository.
134#[derive(Clone, Debug, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct Session {
137 /// `ses_…`.
138 pub id: String,
139 pub repo_id: String,
140 /// The issue it works on, if it said.
141 pub issue: Option<u32>,
142 /// The pull request it works on, if it said.
143 pub pull: Option<u32>,
144 pub branch: Option<String>,
145 /// Whose token started it.
146 pub actor: Principal,
147 pub harness: Harness,
148 /// The harness's own id for the session, which makes starting it again
149 /// return this one.
150 pub harness_session_id: Option<String>,
151 pub title: Option<String>,
152 pub state: SessionState,
153 /// The commit it last said it was at.
154 pub head_commit: Option<String>,
155 /// RFC 3339.
156 pub started_at: String,
157 /// RFC 3339: its last heartbeat or change.
158 pub last_seen_at: String,
159 /// RFC 3339, once ended.
160 pub ended_at: Option<String>,
161 /// Why it ended: what the harness said, or `timed_out`.
162 pub end_reason: Option<String>,
163}
164
165/// Where a claim stands.
166#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum ClaimState {
169 Active,
170 Released,
171 Expired,
172 HandedOff,
173}
174
175impl ClaimState {
176 pub fn as_str(self) -> &'static str {
177 match self {
178 ClaimState::Active => "active",
179 ClaimState::Released => "released",
180 ClaimState::Expired => "expired",
181 ClaimState::HandedOff => "handed_off",
182 }
183 }
184
185 pub fn parse(name: &str) -> Option<ClaimState> {
186 match name.trim().to_ascii_lowercase().as_str() {
187 "active" => Some(ClaimState::Active),
188 "released" => Some(ClaimState::Released),
189 "expired" => Some(ClaimState::Expired),
190 "handed_off" => Some(ClaimState::HandedOff),
191 _ => None,
192 }
193 }
194}
195
196/// An advisory, expiring "I'm working on #N". Never a lock.
197#[derive(Clone, Debug, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct Claim {
200 /// `clm_…`.
201 pub id: String,
202 pub repo_id: String,
203 /// The issue it is on.
204 pub issue: u32,
205 /// The session it was made from, whose heartbeats renew it.
206 pub session_id: Option<String>,
207 pub actor: Principal,
208 pub harness: Option<Harness>,
209 pub note: Option<String>,
210 pub state: ClaimState,
211 /// RFC 3339.
212 pub created_at: String,
213 /// RFC 3339: when it lapses unless renewed.
214 pub expires_at: String,
215 /// RFC 3339, once it stopped being active.
216 pub ended_at: Option<String>,
217 pub end_reason: Option<String>,
218 /// For a hand-off: the username it was handed to; none for anyone.
219 pub handoff_to: Option<String>,
220}
221
222/// How long a claim lasts, in minutes: the default when none was asked,
223/// at least one and at most [`CLAIM_MAX_MINUTES`].
224pub fn claim_minutes(asked: Option<u32>) -> u32 {
225 asked.unwrap_or(CLAIM_DEFAULT_MINUTES).clamp(1, CLAIM_MAX_MINUTES)
226}
227
228/// `blocked_by` as asked for `number`, de-duplicated in the order given,
229/// or why it cannot be: one names `number` itself or is not an issue of
230/// the repository (`issues`), there are too many, or it would close a
231/// cycle through the blockers already recorded (`graph`: each issue's
232/// `blocked_by`).
233pub fn check_blocked_by(
234 number: u32,
235 asked: &[u32],
236 issues: &std::collections::HashSet<u32>,
237 graph: &std::collections::HashMap<u32, Vec<u32>>,
238) -> Result<Vec<u32>, String> {
239 let mut wanted: Vec<u32> = Vec::new();
240 for blocker in asked {
241 if !wanted.contains(blocker) {
242 wanted.push(*blocker);
243 }
244 }
245 if wanted.len() > MAX_BLOCKED_BY {
246 return Err(format!("An issue can be blocked by at most {MAX_BLOCKED_BY} issues."));
247 }
248 if wanted.contains(&number) {
249 return Err(format!("#{number} cannot be blocked by itself."));
250 }
251 if let Some(missing) = wanted.iter().find(|blocker| !issues.contains(blocker)) {
252 return Err(format!("#{missing} is not an issue in this repository."));
253 }
254 // A cycle: some blocker is itself, through what blocks it, blocked by
255 // `number`.
256 let mut seen = std::collections::HashSet::new();
257 let mut stack: Vec<(u32, Vec<u32>)> = wanted.iter().map(|blocker| (*blocker, vec![number, *blocker])).collect();
258 while let Some((at, path)) = stack.pop() {
259 if !seen.insert(at) {
260 continue;
261 }
262 for next in graph.get(&at).into_iter().flatten() {
263 let mut longer = path.clone();
264 longer.push(*next);
265 if *next == number {
266 let chain = longer.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(" → ");
267 return Err(format!("That would make a cycle: {chain}, where each is blocked by the next."));
268 }
269 stack.push((*next, longer));
270 }
271 }
272 Ok(wanted)
273}
274
275/// The items of an issue's "Definition of done" section: each bulleted or
276/// numbered line under the heading, up to the next heading.
277pub fn definition_of_done(body: &str) -> Vec<String> {
278 let mut items = Vec::new();
279 let mut inside = false;
280 for line in body.lines() {
281 let trimmed = line.trim();
282 if trimmed.starts_with('#') {
283 inside = trimmed.trim_start_matches('#').trim().eq_ignore_ascii_case("definition of done");
284 continue;
285 }
286 if !inside {
287 continue;
288 }
289 let item = trimmed
290 .strip_prefix("- ")
291 .or_else(|| trimmed.strip_prefix("* "))
292 .or_else(|| trimmed.split_once(". ").filter(|(n, _)| n.chars().all(|c| c.is_ascii_digit()) && !n.is_empty()).map(|(_, rest)| rest));
293 if let Some(item) = item {
294 let item = item.trim_start_matches("[ ] ").trim_start_matches("[x] ").trim_start_matches("[X] ").trim();
295 if !item.is_empty() {
296 items.push(item.to_owned());
297 }
298 }
299 }
300 items
301}
302
303/// The pairs of pull requests that overlap now, against those already
304/// announced: which are new (to announce) and which cleared (to forget, so
305/// that they are announced again if they come back).
306pub fn overlap_changes(current: &[u32], announced: &[u32]) -> (Vec<u32>, Vec<u32>) {
307 let new = current.iter().filter(|number| !announced.contains(number)).copied().collect();
308 let cleared = announced.iter().filter(|number| !current.contains(number)).copied().collect();
309 (new, cleared)
310}
311
312// --- Arguments --------------------------------------------------------------
313
314/// `start_session`. Returns `Outcome<Session>`: a new one, or the one
315/// already started with the same `harness_session_id` (which then takes
316/// the issue, pull request, branch, title and head given).
317#[derive(Debug, Serialize, Deserialize)]
318pub struct StartSessionArgs {
319 pub actor: User,
320 pub repo: RepoPath,
321 pub harness: Harness,
322 #[serde(default)]
323 pub harness_session_id: Option<String>,
324 #[serde(default)]
325 pub issue: Option<u32>,
326 #[serde(default)]
327 pub pull: Option<u32>,
328 #[serde(default)]
329 pub branch: Option<String>,
330 #[serde(default)]
331 pub title: Option<String>,
332 #[serde(default)]
333 pub head_commit: Option<String>,
334}
335
336/// `update_session`: changes whichever fields are given; with none, it is a
337/// heartbeat. Either way it renews the session's active claims. Returns
338/// `Outcome<Session>`.
339#[derive(Debug, Serialize, Deserialize)]
340pub struct UpdateSessionArgs {
341 pub actor: User,
342 pub repo: RepoPath,
343 pub id: String,
344 #[serde(default)]
345 pub issue: Option<u32>,
346 #[serde(default)]
347 pub pull: Option<u32>,
348 #[serde(default)]
349 pub branch: Option<String>,
350 #[serde(default)]
351 pub head_commit: Option<String>,
352 #[serde(default)]
353 pub title: Option<String>,
354}
355
356/// `end_session`. Returns `Outcome<Session>`; ending one already ended
357/// returns it as it is.
358#[derive(Debug, Serialize, Deserialize)]
359pub struct EndSessionArgs {
360 pub actor: User,
361 pub repo: RepoPath,
362 pub id: String,
363 #[serde(default)]
364 pub reason: Option<String>,
365}
366
367/// `get_work_session`: a session and its entries after `after_seq`.
368/// Returns `Outcome<SessionDetail>`.
369#[derive(Debug, Serialize, Deserialize)]
370pub struct WorkSessionArgs {
371 pub viewer: Viewer,
372 pub repo: RepoPath,
373 pub id: String,
374 #[serde(default)]
375 pub after_seq: u32,
376}
377
378#[derive(Clone, Debug, Serialize, Deserialize)]
379pub struct SessionDetail {
380 pub session: Session,
381 /// Oldest first, numbered within the session.
382 pub entries: Vec<SessionEntry>,
383}
384
385/// `list_work_sessions`: a repository's sessions, most recently heard from
386/// first. Returns `Outcome<Vec<Session>>`.
387#[derive(Debug, Serialize, Deserialize)]
388pub struct ListWorkSessionsArgs {
389 pub viewer: Viewer,
390 pub repo: RepoPath,
391 #[serde(default)]
392 pub state: Option<SessionState>,
393 #[serde(default)]
394 pub issue: Option<u32>,
395 #[serde(default)]
396 pub pull: Option<u32>,
397}
398
399/// An entry to record in a session. `key` makes recording it again a no-op.
400#[derive(Clone, Debug, Serialize, Deserialize)]
401pub struct KeyedSessionEntry {
402 #[serde(flatten)]
403 pub entry: NewSessionEntry,
404 #[serde(default)]
405 pub key: Option<String>,
406}
407
408/// `record_session_entries`. Returns `Outcome<Recorded>`.
409#[derive(Debug, Serialize, Deserialize)]
410pub struct RecordEntriesArgs {
411 pub actor: User,
412 pub repo: RepoPath,
413 pub id: String,
414 pub entries: Vec<KeyedSessionEntry>,
415}
416
417#[derive(Clone, Debug, Default, Serialize, Deserialize)]
418pub struct Recorded {
419 /// Entries newly recorded.
420 pub recorded: u32,
421 /// Entries skipped because one with the same key was recorded before.
422 pub duplicates: u32,
423}
424
425/// `claim_issue`. Returns `Outcome<Claimed>`. Claiming an issue you hold
426/// renews your claim (and changes its note or session when given).
427#[derive(Debug, Serialize, Deserialize)]
428pub struct ClaimArgs {
429 pub actor: User,
430 pub repo: RepoPath,
431 pub number: u32,
432 #[serde(default)]
433 pub session_id: Option<String>,
434 #[serde(default)]
435 pub note: Option<String>,
436 #[serde(default)]
437 pub ttl_minutes: Option<u32>,
438}
439
440#[derive(Clone, Debug, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub struct Claimed {
443 pub claim: Claim,
444 /// Other active claims on the issue: someone else is on it too.
445 pub others: Vec<Claim>,
446 /// What the issue is blocked by, and where each stands.
447 pub blocked_by: Vec<Blocker>,
448}
449
450/// `release_claim`: gives up your active claim on an issue. Returns
451/// `Outcome<Claim>`.
452#[derive(Debug, Serialize, Deserialize)]
453pub struct ReleaseArgs {
454 pub actor: User,
455 pub repo: RepoPath,
456 pub number: u32,
457 #[serde(default)]
458 pub reason: Option<String>,
459}
460
461/// `hand_off_issue`: ends your active claim on an issue as handed off, to
462/// someone (`to`, a username) or anyone, with a note on where it stands.
463/// Returns `Outcome<Claim>`.
464#[derive(Debug, Serialize, Deserialize)]
465pub struct HandOffArgs {
466 pub actor: User,
467 pub repo: RepoPath,
468 pub number: u32,
469 #[serde(default)]
470 pub to: Option<String>,
471 pub note: String,
472}
473
474/// `list_claims`: a repository's claims, newest first; active ones unless
475/// `state` says otherwise. Returns `Outcome<Vec<Claim>>`.
476#[derive(Debug, Serialize, Deserialize)]
477pub struct ListClaimsArgs {
478 pub viewer: Viewer,
479 pub repo: RepoPath,
480 #[serde(default)]
481 pub state: Option<ClaimState>,
482 #[serde(default)]
483 pub issue: Option<u32>,
484}
485
486/// `get_work`: everything an agent needs to pick up an issue. Returns
487/// `Outcome<WorkContext>`.
488#[derive(Debug, Serialize, Deserialize)]
489pub struct GetWorkArgs {
490 pub viewer: Viewer,
491 pub repo: RepoPath,
492 pub number: u32,
493}
494
495/// An issue in brief.
496#[derive(Clone, Debug, Serialize, Deserialize)]
497pub struct IssueBrief {
498 pub number: u32,
499 pub title: String,
500 pub state: State,
501}
502
503/// A pull request in brief.
504#[derive(Clone, Debug, Serialize, Deserialize)]
505pub struct PullBrief {
506 pub number: u32,
507 pub title: String,
508 pub status: PullStatus,
509}
510
511/// An issue another is blocked by, and where it stands.
512#[derive(Clone, Debug, Serialize, Deserialize)]
513pub struct Blocker {
514 pub number: u32,
515 pub title: String,
516 pub state: State,
517 /// Active claims on it.
518 pub claims: Vec<Claim>,
519 /// Its pull requests.
520 pub pulls: Vec<PullBrief>,
521}
522
523/// A pull request's place in the merge queue.
524#[derive(Clone, Debug, Serialize, Deserialize)]
525pub struct QueuePlace {
526 pub state: QueueState,
527 /// 1 for the front of the queue.
528 pub position: u32,
529}
530
531/// One of an issue's pull requests, with where it stands.
532#[derive(Clone, Debug, Serialize, Deserialize)]
533#[serde(rename_all = "camelCase")]
534pub struct WorkPull {
535 pub number: u32,
536 pub title: String,
537 pub status: PullStatus,
538 pub branch: Option<String>,
539 pub head_commit: Option<String>,
540 pub author: Principal,
541 /// `failed` when the merge queue took it out, until its head moves.
542 pub check_status: Option<CheckStatus>,
543 /// The checks the branch it merges into requires, as they stand on its
544 /// head.
545 pub required_checks: Vec<RequiredCheck>,
546 pub mergeable: Mergeable,
547 /// Other pull requests in progress changing the same files.
548 pub overlaps: Vec<Overlap>,
549 /// Its place in the merge queue, while it is in it.
550 pub queue: Option<QueuePlace>,
551}
552
553/// Everything about an issue an agent needs to pick it up: the one call to
554/// make before starting.
555#[derive(Clone, Debug, Serialize, Deserialize)]
556#[serde(rename_all = "camelCase")]
557pub struct WorkContext {
558 pub issue: Issue,
559 /// The items under its "Definition of done" heading.
560 pub definition_of_done: Vec<String>,
561 /// The checks every pull request into the default branch must pass.
562 pub required_checks: Vec<String>,
563 /// Active claims on it.
564 pub claims: Vec<Claim>,
565 /// Sessions on it or its pull requests, most recently heard from first.
566 pub sessions: Vec<Session>,
567 pub pulls: Vec<WorkPull>,
568 /// What it is blocked by, and where each stands.
569 pub blocked_by: Vec<Blocker>,
570 /// The issues it blocks.
571 pub blocks: Vec<IssueBrief>,
572 /// Decision records on it and its pull requests, newest first.
573 #[serde(default)]
574 pub decisions: Vec<crate::decisions::Decision>,
575 /// The latest `integration_failure` decision on it or its pull
576 /// requests, if any: which combination failed, and the check.
577 #[serde(default)]
578 pub last_integration_failure: Option<crate::decisions::Decision>,
579 /// The latest coordination events about it, newest first.
580 pub recent: Vec<Event>,
581}
582
583// --- Events -----------------------------------------------------------------
584
585/// `session.started` and `session.ended`.
586#[derive(Clone, Debug, Serialize, Deserialize)]
587#[serde(rename_all = "camelCase")]
588pub struct SessionEvent {
589 pub repo_id: String,
590 /// The pull request it works on, else its issue.
591 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub number: Option<u32>,
593 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub issue: Option<u32>,
595 pub session: Session,
596}
597
598impl SessionEvent {
599 pub fn of(session: &Session) -> SessionEvent {
600 SessionEvent {
601 repo_id: session.repo_id.clone(),
602 number: session.pull.or(session.issue),
603 issue: session.issue,
604 session: session.clone(),
605 }
606 }
607}
608
609/// `work.claimed`, `work.released`, `work.claim_expired` and
610/// `work.handoff_requested`: `number` and `issue` are the issue claimed.
611#[derive(Clone, Debug, Serialize, Deserialize)]
612#[serde(rename_all = "camelCase")]
613pub struct ClaimEvent {
614 pub repo_id: String,
615 pub number: u32,
616 pub issue: u32,
617 pub claim: Claim,
618 /// On `work.claimed`: the other active claims on the issue.
619 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub others: Option<Vec<Claim>>,
621 /// On `work.claimed`, when it takes over a claim handed off.
622 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub took_over: Option<String>,
624 /// On `work.released`.
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub reason: Option<String>,
627 /// On `work.handoff_requested`: who handed it off.
628 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub from: Option<String>,
630 /// On `work.handoff_requested`: who it is for; absent for anyone.
631 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub to: Option<String>,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub note: Option<String>,
635}
636
637impl ClaimEvent {
638 pub fn of(claim: &Claim) -> ClaimEvent {
639 ClaimEvent {
640 repo_id: claim.repo_id.clone(),
641 number: claim.issue,
642 issue: claim.issue,
643 claim: claim.clone(),
644 others: None,
645 took_over: None,
646 reason: None,
647 from: None,
648 to: None,
649 note: None,
650 }
651 }
652}
653
654/// `dependency.changed`: an issue's `blocked_by` changed, or one of the
655/// issues it is blocked by closed or had its pull request merged.
656#[derive(Clone, Debug, Default, Serialize, Deserialize)]
657#[serde(rename_all = "camelCase")]
658pub struct DependencyChanged {
659 pub repo_id: String,
660 /// The issue whose dependencies changed.
661 pub number: u32,
662 pub issue: u32,
663 /// What it is blocked by now.
664 pub blocked_by: Vec<u32>,
665 /// Added to `blocked_by`.
666 pub added: Vec<u32>,
667 /// Taken out of `blocked_by`.
668 pub removed: Vec<u32>,
669 /// Blockers that just closed or had a pull request merged.
670 pub cleared: Vec<u32>,
671 /// Those of `blocked_by` still open: none means it is unblocked.
672 pub remaining: Vec<u32>,
673 /// When a merge cleared it: the pull request merged.
674 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub merged_pull: Option<u32>,
676}
677
678/// `overlap.detected`: a pull request's latest push makes it change files
679/// another open or draft pull request changes. Sent once for each side
680/// when a pair starts overlapping, and again only after it stopped.
681#[derive(Clone, Debug, Serialize, Deserialize)]
682#[serde(rename_all = "camelCase")]
683pub struct OverlapDetected {
684 pub repo_id: String,
685 /// The pull request this event is about: `pull`.
686 pub number: u32,
687 pub pull: u32,
688 pub other_pull: u32,
689 /// The files both change.
690 pub files: Vec<String>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub issue: Option<u32>,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub other_issue: Option<u32>,
695}
696
697#[cfg(test)]
698mod tests {
699 use std::collections::{HashMap, HashSet};
700
701 use super::*;
702
703 fn issues(numbers: &[u32]) -> HashSet<u32> {
704 numbers.iter().copied().collect()
705 }
706
707 #[test]
708 fn a_session_goes_idle_then_ends_without_heartbeats() {
709 let start = 1_000_000;
710 assert_eq!(SessionState::at(false, start, start), SessionState::Active);
711 assert_eq!(SessionState::at(false, start, start + SESSION_IDLE_MS - 1), SessionState::Active);
712 assert_eq!(SessionState::at(false, start, start + SESSION_IDLE_MS), SessionState::Idle);
713 assert_eq!(SessionState::at(false, start, start + SESSION_TIMEOUT_MS), SessionState::Ended);
714 assert_eq!(SessionState::at(true, start, start), SessionState::Ended);
715 // A clock behind the last heartbeat reads as just heard from.
716 assert_eq!(SessionState::at(false, start, start - 5), SessionState::Active);
717 }
718
719 #[test]
720 fn claims_last_thirty_minutes_up_to_four_hours() {
721 assert_eq!(claim_minutes(None), 30);
722 assert_eq!(claim_minutes(Some(0)), 1);
723 assert_eq!(claim_minutes(Some(90)), 90);
724 assert_eq!(claim_minutes(Some(10_000)), 240);
725 }
726
727 #[test]
728 fn harnesses_are_named_one_way() {
729 assert_eq!(Harness::parse("claude-code"), Some(Harness::ClaudeCode));
730 assert_eq!(Harness::parse("Codex"), Some(Harness::Codex));
731 assert_eq!(Harness::parse("vim"), None);
732 assert_eq!(serde_json::to_value(Harness::ClaudeCode).unwrap(), "claude_code");
733 assert_eq!(serde_json::to_value(ClaimState::HandedOff).unwrap(), "handed_off");
734 }
735
736 #[test]
737 fn blocked_by_is_issues_of_the_repository_never_itself() {
738 let graph = HashMap::new();
739 assert_eq!(check_blocked_by(5, &[3, 4, 3], &issues(&[3, 4, 5]), &graph), Ok(vec![3, 4]));
740 assert_eq!(check_blocked_by(5, &[], &issues(&[5]), &graph), Ok(vec![]));
741 assert!(check_blocked_by(5, &[5], &issues(&[5]), &graph).unwrap_err().contains("itself"));
742 assert!(check_blocked_by(5, &[9], &issues(&[5]), &graph).unwrap_err().contains("#9 is not an issue"));
743 let many: Vec<u32> = (100..100 + MAX_BLOCKED_BY as u32 + 1).collect();
744 assert!(check_blocked_by(5, &many, &many.iter().copied().collect(), &graph).is_err());
745 }
746
747 #[test]
748 fn blocked_by_never_makes_a_cycle() {
749 // 3 is blocked by 4, which is blocked by 5.
750 let graph: HashMap<u32, Vec<u32>> = [(3, vec![4]), (4, vec![5])].into_iter().collect();
751 let all = issues(&[3, 4, 5, 6]);
752 let refused = check_blocked_by(5, &[3], &all, &graph).unwrap_err();
753 assert!(refused.contains("#5 → #3 → #4 → #5"), "{refused}");
754 // A diamond is not a cycle.
755 let diamond: HashMap<u32, Vec<u32>> = [(3, vec![5]), (4, vec![5])].into_iter().collect();
756 assert_eq!(check_blocked_by(6, &[3, 4], &all, &diamond), Ok(vec![3, 4]));
757 // Replacing 5's own blockers is judged without them.
758 let graph: HashMap<u32, Vec<u32>> = [(5, vec![3]), (3, vec![4])].into_iter().collect();
759 assert_eq!(check_blocked_by(5, &[4], &all, &graph), Ok(vec![4]));
760 }
761
762 #[test]
763 fn the_definition_of_done_is_read_from_its_section() {
764 let body = "Make it greet.\n\n## Definition of done\n\n- `npm test` passes.\n- [ ] Docs say so\n1. Works offline\n\n## Notes\n- not this";
765 assert_eq!(definition_of_done(body), ["`npm test` passes.", "Docs say so", "Works offline"]);
766 assert!(definition_of_done("No section").is_empty());
767 }
768
769 #[test]
770 fn an_overlap_is_announced_once_until_it_clears() {
771 assert_eq!(overlap_changes(&[7, 9], &[]), (vec![7, 9], vec![]));
772 // Pushing again with the same overlaps announces nothing.
773 assert_eq!(overlap_changes(&[7, 9], &[7, 9]), (vec![], vec![]));
774 // 9 stopped overlapping: forgotten, so a later overlap is new again.
775 assert_eq!(overlap_changes(&[7], &[7, 9]), (vec![], vec![9]));
776 assert_eq!(overlap_changes(&[7, 9], &[7]), (vec![9], vec![]));
777 }
778
779 #[test]
780 fn events_name_the_issue_or_pull_request_they_are_about() {
781 let session: Session = serde_json::from_value(serde_json::json!({
782 "id": "ses_1", "repoId": "rep_1", "issue": 4, "pull": 9, "branch": null,
783 "actor": { "id": "usr_1", "username": "ana" }, "harness": "claude_code",
784 "harnessSessionId": null, "title": null, "state": "active", "headCommit": null,
785 "startedAt": "", "lastSeenAt": "", "endedAt": null, "endReason": null
786 }))
787 .unwrap();
788 let event = serde_json::to_value(SessionEvent::of(&session)).unwrap();
789 assert_eq!((event["number"].as_u64(), event["issue"].as_u64()), (Some(9), Some(4)));
790 }
791}