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
264 linesCodeBlameRaw
1//! Claims: an advisory, expiring "I'm working on #N".
2//!
3//! A claim is never a lock: anyone may claim an issue someone else has
4//! claimed, and is told who else is on it. It lapses unless renewed, by
5//! claiming again or by a heartbeat of the session it was made from, and
6//! ends when released, handed off, or when its issue closes.
7
8use g1t_contracts::identity::UsernameArgs;
9use g1t_contracts::time::rfc3339;
10use g1t_contracts::work::*;
11use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
12use g1t_kit::now_ms;
13use worker::Result;
14use worker::wasm_bindgen::JsValue;
15
16use super::{CLAIM_COLUMNS, ClaimRow, after_minutes, tidy, unwrap_or_fail};
17use crate::Work;
18
19/// The most claims one list returns.
20const LIST_LIMIT: u32 = 100;
21
22fn text_or_null(value: &Option<String>) -> JsValue {
23 value.as_deref().map_or(JsValue::NULL, JsValue::from)
24}
25
26impl Work {
27 /// The caller's active claim on an issue, if they hold one.
28 async fn own_claim(&self, repo_id: &str, issue: u32, actor: &User) -> Result<Option<Claim>> {
29 Ok(self.active_claims(repo_id, issue).await?.into_iter().find(|claim| claim.actor.id == actor.id))
30 }
31
32 pub(crate) async fn claim_issue(&self, a: ClaimArgs) -> Result<Outcome<Claimed>> {
33 let repo = unwrap_or_fail!(self.participating_repo(&a.repo, &a.actor).await?);
34 let Some(issue) = self.issue(&repo.id, a.number).await? else {
35 return Ok(Outcome::fail(FailureCode::NotFound, "Issue not found."));
36 };
37 if issue.state == State::Closed {
38 return Ok(Outcome::fail(FailureCode::Conflict, format!("#{} is closed.", issue.number)));
39 }
40 // The session it is made from, whose heartbeats renew it.
41 let session = match a.session_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
42 Some(id) => match self.session_row(&repo.id, id).await? {
43 Some(row) if row.actor_id == a.actor.id => Some(row.at(now_ms())),
44 _ => return Ok(Outcome::fail(FailureCode::Invalid, "session_id is not one of your sessions in this repository.")),
45 },
46 None => None,
47 };
48 if session.as_ref().is_some_and(|session| session.state == SessionState::Ended) {
49 return Ok(Outcome::fail(FailureCode::Invalid, "That session has ended. Start a new one."));
50 }
51 let minutes = claim_minutes(a.ttl_minutes);
52 let note = tidy(a.note.as_deref(), MAX_NOTE_CHARS);
53 let session_id = session.as_ref().map(|session| session.id.clone());
54 let harness = session.as_ref().map(|session| session.harness.as_str().to_owned());
55 let now = now_ms();
56 let expires_at = after_minutes(now, minutes);
57 let standing = self.active_claims(&repo.id, issue.number).await?;
58 let (mine, others): (Vec<Claim>, Vec<Claim>) = standing.into_iter().partition(|claim| claim.actor.id == a.actor.id);
59 let blocked_by = self.blockers(&repo.id, &issue.blocked_by).await?;
60 // Claiming what you hold renews it.
61 if let Some(held) = mine.into_iter().next() {
62 let renewed = self
63 .db
64 .prepare(format!(
65 "UPDATE claims
66 SET expires_at = ?, ttl_minutes = ?, note = COALESCE(?, note),
67 session_id = COALESCE(?, session_id), harness = COALESCE(?, harness)
68 WHERE id = ? AND state = 'active'
69 RETURNING {CLAIM_COLUMNS}"
70 ))
71 .bind(&[
72 expires_at.as_str().into(),
73 minutes.into(),
74 text_or_null(&note),
75 text_or_null(&session_id),
76 text_or_null(&harness),
77 held.id.as_str().into(),
78 ])?
79 .first::<ClaimRow>(None)
80 .await?;
81 if let Some(renewed) = renewed {
82 return Ok(Outcome::Ok(Claimed { claim: renewed.into(), others, blocked_by }));
83 }
84 }
85 // Taking over what was handed off to you, or to anyone.
86 let handed: Option<ClaimRow> = self
87 .db
88 .prepare(format!(
89 "SELECT {CLAIM_COLUMNS} FROM claims
90 WHERE repo_id = ? AND issue = ? AND state <> 'active' ORDER BY ended_at DESC, id DESC LIMIT 1"
91 ))
92 .bind(&[repo.id.as_str().into(), issue.number.into()])?
93 .first::<ClaimRow>(None)
94 .await?;
95 let took_over = handed
96 .map(Claim::from)
97 .filter(|last| last.state == ClaimState::HandedOff)
98 .filter(|last| last.handoff_to.as_deref().is_none_or(|to| to == a.actor.username))
99 .map(|last| last.id);
100 let at = rfc3339(now);
101 let created = self
102 .db
103 .prepare(format!(
104 "INSERT INTO claims
105 (id, repo_id, issue, session_id, actor_id, actor_name, harness, note, state, ttl_minutes,
106 created_at, expires_at)
107 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
108 RETURNING {CLAIM_COLUMNS}"
109 ))
110 .bind(&[
111 new_id("clm", now).into(),
112 repo.id.as_str().into(),
113 issue.number.into(),
114 text_or_null(&session_id),
115 a.actor.id.as_str().into(),
116 a.actor.username.as_str().into(),
117 text_or_null(&harness),
118 text_or_null(&note),
119 minutes.into(),
120 at.as_str().into(),
121 expires_at.as_str().into(),
122 ])?
123 .first::<ClaimRow>(None)
124 .await?;
125 let Some(created) = created else {
126 return Err(worker::Error::RustError("the claim was not recorded".into()));
127 };
128 let claim = Claim::from(created);
129 self.publish(
130 "work.claimed",
131 &repo.id,
132 &a.actor,
133 ClaimEvent { others: Some(others.clone()), took_over, ..ClaimEvent::of(&claim) },
134 )
135 .await?;
136 Ok(Outcome::Ok(Claimed { claim, others, blocked_by }))
137 }
138
139 /// Ends the caller's active claim on an issue as `state`, or refuses
140 /// when they hold none.
141 async fn end_own_claim(
142 &self,
143 actor: &User,
144 repo: &g1t_contracts::repos::RepoPath,
145 number: u32,
146 state: ClaimState,
147 reason: &str,
148 handoff: Option<(&Option<String>, &str)>,
149 ) -> Result<Outcome<Claim>> {
150 let repo = unwrap_or_fail!(self.participating_repo(repo, actor).await?);
151 let Some(held) = self.own_claim(&repo.id, number, actor).await? else {
152 return Ok(Outcome::fail(FailureCode::NotFound, format!("You hold no claim on #{number}.")));
153 };
154 let at = rfc3339(now_ms());
155 let (handoff_to, note) = match handoff {
156 Some((to, note)) => (text_or_null(to), JsValue::from(note)),
157 None => (JsValue::NULL, JsValue::NULL),
158 };
159 let ended = self
160 .db
161 .prepare(format!(
162 "UPDATE claims
163 SET state = ?, ended_at = ?, end_reason = ?, handoff_to = ?, note = COALESCE(?, note)
164 WHERE id = ? AND state = 'active'
165 RETURNING {CLAIM_COLUMNS}"
166 ))
167 .bind(&[state.as_str().into(), at.into(), reason.into(), handoff_to, note, held.id.as_str().into()])?
168 .first::<ClaimRow>(None)
169 .await?;
170 Ok(match ended {
171 Some(row) => Outcome::Ok(row.into()),
172 None => Outcome::fail(FailureCode::NotFound, format!("You hold no claim on #{number}.")),
173 })
174 }
175
176 pub(crate) async fn release_claim(&self, a: ReleaseArgs) -> Result<Outcome<Claim>> {
177 let reason = tidy(a.reason.as_deref(), MAX_NOTE_CHARS).unwrap_or_else(|| "released".to_owned());
178 let claim = unwrap_or_fail!(
179 self.end_own_claim(&a.actor, &a.repo, a.number, ClaimState::Released, &reason, None).await?
180 );
181 self.publish(
182 "work.released",
183 &claim.repo_id.clone(),
184 &a.actor,
185 ClaimEvent { reason: Some(reason), ..ClaimEvent::of(&claim) },
186 )
187 .await?;
188 Ok(Outcome::Ok(claim))
189 }
190
191 pub(crate) async fn hand_off_issue(&self, a: HandOffArgs) -> Result<Outcome<Claim>> {
192 let Some(note) = tidy(Some(&a.note), MAX_NOTE_CHARS) else {
193 return Ok(Outcome::fail(FailureCode::Invalid, "Say where the work stands in note, for whoever picks it up."));
194 };
195 let to = tidy(a.to.as_deref().map(|to| to.trim().trim_start_matches('@')), 100).map(|to| to.to_lowercase());
196 if let Some(name) = &to {
197 let account: Viewer =
198 g1t_kit::call(&self.identity, "user_by_username", &UsernameArgs { username: name.clone() }).await?;
199 if account.is_none() {
200 return Ok(Outcome::fail(FailureCode::Invalid, format!("There is no account named {name}.")));
201 }
202 }
203 let claim = unwrap_or_fail!(
204 self.end_own_claim(&a.actor, &a.repo, a.number, ClaimState::HandedOff, "handed_off", Some((&to, &note)))
205 .await?
206 );
207 self.publish(
208 "work.handoff_requested",
209 &claim.repo_id.clone(),
210 &a.actor,
211 ClaimEvent { from: Some(a.actor.username.clone()), to, note: Some(note), ..ClaimEvent::of(&claim) },
212 )
213 .await?;
214 Ok(Outcome::Ok(claim))
215 }
216
217 pub(crate) async fn list_claims(&self, a: ListClaimsArgs) -> Result<Outcome<Vec<Claim>>> {
218 let repo = unwrap_or_fail!(self.coordination_repo(&a.repo, &a.viewer).await?);
219 let state = a.state.unwrap_or(ClaimState::Active);
220 let rows = self
221 .db
222 .prepare(format!(
223 "SELECT {CLAIM_COLUMNS} FROM claims
224 WHERE repo_id = ?1 AND state = ?2 AND (?3 IS NULL OR issue = ?3)
225 ORDER BY created_at DESC, id DESC LIMIT ?4"
226 ))
227 .bind(&[
228 repo.id.as_str().into(),
229 state.as_str().into(),
230 a.issue.filter(|number| *number > 0).map_or(JsValue::NULL, JsValue::from),
231 LIST_LIMIT.into(),
232 ])?
233 .all()
234 .await?
235 .results::<ClaimRow>()?;
236 Ok(Outcome::Ok(rows.into_iter().map(Claim::from).collect()))
237 }
238
239 /// An issue closed: its active claims end as released (`issue_closed`),
240 /// each announced.
241 pub(crate) async fn release_claims_on_close(&self, repo_id: &str, issue: u32, actor: Option<&User>) -> Result<()> {
242 let at = rfc3339(now_ms());
243 let released = self
244 .db
245 .prepare(format!(
246 "UPDATE claims SET state = 'released', ended_at = ?, end_reason = 'issue_closed'
247 WHERE repo_id = ? AND issue = ? AND state = 'active'
248 RETURNING {CLAIM_COLUMNS}"
249 ))
250 .bind(&[at.into(), repo_id.into(), issue.into()])?
251 .all()
252 .await?
253 .results::<ClaimRow>()?;
254 for row in released {
255 let claim = Claim::from(row);
256 let data = ClaimEvent { reason: Some("issue_closed".to_owned()), ..ClaimEvent::of(&claim) };
257 match actor {
258 Some(actor) => self.publish("work.released", repo_id, actor, data).await?,
259 None => self.publish_as("work.released", repo_id, None, data).await?,
260 }
261 }
262 Ok(())
263 }
264}