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
709 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! Planning: an outcome someone wrote, turned by an agent into issues and
2//! the order they have to land in.
3//!
4//! This service keeps the plan. The runner starts the sandbox in which an
5//! agent reads the repository and writes it, and the sandbox reports back
6//! with the plan's one-time token. A person then reads the proposal and
7//! applies it, which opens the issues. Issues that depend on others are
8//! held back and handed to a g1t agent when what they depend on has
9//! merged, so that each starts from the result of the last.
10
11use std::collections::HashMap;
12
13use g1t_contracts::access::Capability;
14use g1t_contracts::events::IssueEvent;
Queued issues are never dropped on the way to an agent, and a start that fails says why15use g1t_contracts::repos::{PathByIdArgs, Repo, RepoPath};
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor16use g1t_contracts::time::rfc3339;
17use g1t_contracts::work::*;
18use g1t_contracts::{FailureCode, Outcome, User, new_id};
19use g1t_kit::now_ms;
20use serde::Deserialize;
21use sha2::{Digest, Sha256};
22use worker::Result;
23use worker::wasm_bindgen::JsValue;
24
25use crate::rows::user;
26use crate::{MAX_TITLE_CHARS, Work, valid_title};
27
28const MAX_BRIEF_CHARS: usize = 8_000;
29const MAX_PLANNED_ISSUES: usize = 12;
30const MAX_BODY_CHARS: usize = 20_000;
31const PLAN_PAGE: u32 = 20;
32/// Planning that has not reported back in this long has failed.
33const PLANNING_MINUTES: u64 = 20;
34/// How many g1t agents make changes in one repository at once. The rest of
35/// a plan waits its turn, which also keeps a large plan from flooding the
36/// sandboxes that checks and reviews need.
37const MAX_AGENTS_AT_WORK: usize = 6;
38
39#[derive(Deserialize)]
40struct PlanRow {
41 id: String,
42 repo_id: String,
43 brief: String,
44 status: PlanStatus,
45 summary: Option<String>,
46 issues: String,
47 error: Option<String>,
48 token_hash: String,
49 author_id: String,
50 author_name: String,
51 created_at: String,
52 finished_at: Option<String>,
53}
54
55impl From<PlanRow> for Plan {
56 fn from(row: PlanRow) -> Self {
57 // A sandbox that never reported is not still planning.
58 let oldest = rfc3339(now_ms().saturating_sub(PLANNING_MINUTES * 60 * 1000));
59 let abandoned = row.status == PlanStatus::Planning && row.created_at < oldest;
60 Plan {
61 id: row.id,
62 repo_id: row.repo_id,
63 brief: row.brief,
64 status: if abandoned {
65 PlanStatus::Failed
66 } else {
67 row.status
68 },
69 summary: row.summary.unwrap_or_default(),
70 issues: serde_json::from_str(&row.issues).unwrap_or_default(),
71 error: row
72 .error
73 .or_else(|| abandoned.then(|| "The planner did not report back.".to_owned())),
74 author: user(row.author_id, row.author_name),
75 created_at: row.created_at,
76 finished_at: row.finished_at,
77 progress: Vec::new(),
78 exchanges: Vec::new(),
79 }
80 }
81}
82
83#[derive(Deserialize)]
84struct LatestPull {
85 number: u32,
86 agent: String,
87 status: String,
88 stage: Option<String>,
89 stage_detail: Option<String>,
90}
91
92/// An issue waiting for a g1t agent, as selected.
93#[derive(Deserialize)]
94struct QueuedRow {
95 repo_id: String,
96 number: u32,
97 queued_by: String,
98}
99
100fn hash(token: &str) -> String {
101 hex::encode(Sha256::digest(token.as_bytes()))
102}
103
104/// Tidies what an agent proposed into something that can be opened as it
105/// is: bounded, with titles that are valid and dependencies that point only
106/// at earlier issues.
107pub(crate) fn tidy(proposed: Vec<PlannedIssue>) -> Vec<PlannedIssue> {
108 let mut issues: Vec<PlannedIssue> = Vec::new();
109 for issue in proposed.into_iter().take(MAX_PLANNED_ISSUES) {
110 // Nothing is dropped, so that the positions later issues depend on
111 // stay what the agent meant.
112 let title = match valid_title(&issue.title) {
113 Ok(title) => title.to_owned(),
114 Err(_) => match issue.title.trim() {
115 "" => "Untitled change".to_owned(),
116 long => long.chars().take(MAX_TITLE_CHARS).collect(),
117 },
118 };
119 let position = issues.len() as u32 + 1;
120 let mut depends_on: Vec<u32> = issue
121 .depends_on
122 .into_iter()
123 .filter(|earlier| (1..position).contains(earlier))
124 .collect();
125 depends_on.sort_unstable();
126 depends_on.dedup();
127 issues.push(PlannedIssue {
128 title,
129 body: issue.body.trim().chars().take(MAX_BODY_CHARS).collect(),
130 labels: normalize_labels(&issue.labels).unwrap_or_default(),
131 done: issue
132 .done
133 .into_iter()
134 .map(|item| item.trim().to_owned())
135 .filter(|item| !item.is_empty())
136 .take(10)
137 .collect(),
138 files: issue.files.into_iter().take(40).collect(),
139 depends_on,
140 number: None,
141 });
142 }
143 issues
144}
145
146impl Work {
147 async fn plan_row(&self, id: &str) -> Result<Option<PlanRow>> {
148 self.db
149 .prepare("SELECT * FROM plans WHERE id = ?")
150 .bind(&[id.into()])?
151 .first::<PlanRow>(None)
152 .await
153 }
154
155 /// Records an outcome to plan for, and returns what a sandbox needs to
156 /// plan it. Needs the Write role: a plan becomes issues and agents at
157 /// work, which the workspace pays for.
158 pub(crate) async fn start_plan(&self, a: StartPlanArgs) -> Result<Outcome<PlanJob>> {
159 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
160 Outcome::Ok(repo) => repo,
161 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
162 };
163 if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
164 return Ok(Outcome::Fail(failure));
165 }
166 if let Some(refused) = may_plan(&a.actor, &repo) {
167 return Ok(refused);
168 }
169 let brief: String = a.brief.trim().chars().take(MAX_BRIEF_CHARS).collect();
170 if brief.is_empty() {
171 return Ok(Outcome::fail(
172 FailureCode::Invalid,
173 "Say what you want to be true when the work is done.",
174 ));
175 }
176 let now = now_ms();
177 let id = new_id("pln", now);
178 let mut bytes = [0u8; 32];
179 getrandom::getrandom(&mut bytes).expect("no source of randomness");
180 let token = hex::encode(bytes);
181 self.db
182 .prepare(
183 "INSERT INTO plans
184 (id, repo_id, brief, token_hash, author_id, author_name, created_at)
185 VALUES (?, ?, ?, ?, ?, ?, ?)",
186 )
187 .bind(&[
188 id.as_str().into(),
189 repo.id.as_str().into(),
190 brief.as_str().into(),
191 hash(&token).into(),
192 a.actor.id.as_str().into(),
193 a.actor.username.as_str().into(),
194 rfc3339(now).into(),
195 ])?
196 .run()
197 .await?;
198 Ok(Outcome::Ok(PlanJob {
199 plan_id: id,
200 token,
201 brief,
202 repo: RepoPath {
203 namespace: repo.namespace,
204 name: repo.name,
205 },
206 }))
207 }
208
209 /// Records the plan a sandbox's agent wrote, or why it could not write
210 /// one. The plan's token is the only credential.
211 pub(crate) async fn report_plan(&self, a: ReportPlanArgs) -> Result<Outcome<bool>> {
212 let row = self
213 .plan_row(&a.plan_id)
214 .await?
215 .filter(|row| row.token_hash == hash(&a.token));
216 let Some(row) = row else {
217 return Ok(Outcome::fail(FailureCode::NotFound, "Plan not found."));
218 };
219 if row.status != PlanStatus::Planning {
220 return Ok(Outcome::fail(
221 FailureCode::Conflict,
222 "This plan has already been reported.",
223 ));
224 }
225 let issues = tidy(a.issues);
226 let error = a.error.or_else(|| {
227 issues
228 .is_empty()
229 .then(|| "The planner proposed no issues.".to_owned())
230 });
231 self.db
232 .prepare(
233 "UPDATE plans SET status = ?, summary = ?, issues = ?, error = ?, finished_at = ?
234 WHERE id = ? AND status = 'planning'",
235 )
236 .bind(&[
237 if error.is_some() { "failed" } else { "ready" }.into(),
238 a.summary.trim().into(),
239 serde_json::to_string(&issues)?.into(),
240 error.as_deref().map_or(JsValue::NULL, JsValue::from),
241 rfc3339(now_ms()).into(),
242 row.id.into(),
243 ])?
244 .run()
245 .await?;
246 Ok(Outcome::Ok(true))
247 }
248
249 pub(crate) async fn get_plan(&self, a: PlanArgs) -> Result<Outcome<Plan>> {
250 let repo = match self.repo(&a.repo, &a.viewer).await? {
251 Outcome::Ok(repo) => repo,
252 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
253 };
254 // The repos service found it for the viewer, so they can read it,
255 // and whoever can read a repository can read its plans.
256 Ok(
257 match self
258 .plan_row(&a.id)
259 .await?
260 .filter(|row| row.repo_id == repo.id)
261 {
262 Some(row) => {
263 let mut plan: Plan = row.into();
264 if plan.status == PlanStatus::Applied {
265 plan.progress = self.progress(&repo.id, &plan).await?;
266 let numbers: Vec<u32> = plan
267 .progress
268 .iter()
269 .flat_map(|item| std::iter::once(item.number).chain(item.pull))
270 .collect();
271 plan.exchanges = self.exchanges(&repo.id, &numbers).await?;
272 }
273 Outcome::Ok(plan)
274 }
275 None => Outcome::fail(FailureCode::NotFound, "Plan not found."),
276 },
277 )
278 }
279
280 /// Where each issue an applied plan opened stands now, all at once.
281 async fn progress(&self, repo_id: &str, plan: &Plan) -> Result<Vec<IssueProgress>> {
282 let numbers: Vec<u32> = plan.issues.iter().filter_map(|issue| issue.number).collect();
283 let issues = futures_util::future::try_join_all(
284 numbers.iter().map(|number| self.issue(repo_id, *number)),
285 )
286 .await?;
287 let pulls = futures_util::future::try_join_all(numbers.iter().map(|number| async move {
288 self.db
289 .prepare(
290 "SELECT number, agent, status, stage, stage_detail FROM pulls
291 WHERE repo_id = ? AND issue_number = ? AND status != 'closed'
292 ORDER BY number DESC LIMIT 1",
293 )
294 .bind(&[repo_id.into(), (*number).into()])?
295 .first::<LatestPull>(None)
296 .await
297 }))
298 .await?;
299 let open: std::collections::HashSet<u32> = issues
300 .iter()
301 .flatten()
302 .filter(|issue| issue.state == State::Open)
303 .map(|issue| issue.number)
304 .collect();
305 Ok(issues
306 .into_iter()
307 .zip(pulls)
308 .filter_map(|(issue, pull)| {
309 let issue = issue?;
310 let blocked_by: Vec<u32> = issue
311 .blocked_by
312 .iter()
313 .copied()
314 .filter(|number| open.contains(number))
315 .collect();
316 let (state, detail) = if issue.state == State::Closed {
317 match (issue.reason, issue.resolved_by) {
318 (Some(IssueReason::Completed), Some(by)) => {
319 ("landed".to_owned(), format!("Landed with #{by}."))
320 }
321 _ => ("closed".to_owned(), "Closed without landing.".to_owned()),
322 }
323 } else if let Some(pull) = pull.as_ref().filter(|pull| pull.status != "merged") {
324 (
325 pull.stage.clone().unwrap_or_else(|| {
326 if pull.status == "draft" { "working" } else { "ready" }.to_owned()
327 }),
328 pull.stage_detail.clone().unwrap_or_default(),
329 )
330 } else if !blocked_by.is_empty() {
331 let named: Vec<String> = blocked_by.iter().map(|n| format!("#{n}")).collect();
332 ("blocked".to_owned(), format!("Waiting for {} to land.", named.join(", ")))
333 } else if issue.queued {
334 ("waiting".to_owned(), "Waiting for an agent to be free.".to_owned())
335 } else {
336 ("open".to_owned(), "Nobody is working on it.".to_owned())
337 };
338 Some(IssueProgress {
339 number: issue.number,
340 title: issue.title,
341 state,
342 detail,
343 blocked_by,
344 pull: pull.as_ref().map(|pull| pull.number),
345 agent: pull.map(|pull| pull.agent).or(issue.agent),
346 })
347 })
348 .collect())
349 }
350
351 pub(crate) async fn list_plans(&self, a: ViewArgs) -> Result<Outcome<Vec<Plan>>> {
352 let repo = match self.repo(&a.repo, &a.viewer).await? {
353 Outcome::Ok(repo) => repo,
354 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
355 };
356 // Found for the viewer: they can read it, and so its plans.
357 let rows = self
358 .db
359 .prepare("SELECT * FROM plans WHERE repo_id = ? ORDER BY id DESC LIMIT ?")
360 .bind(&[repo.id.into(), PLAN_PAGE.into()])?
361 .all()
362 .await?
363 .results::<PlanRow>()?;
364 Ok(Outcome::Ok(rows.into_iter().map(Plan::from).collect()))
365 }
366
367 /// Opens a plan's issues. Each depends on the issues the plan said it
368 /// does, by their new numbers. With `assign`, every one is queued for a
369 /// g1t agent: those that depend on nothing are ready at once, and the
370 /// rest as what they depend on merges.
371 pub(crate) async fn apply_plan(&self, a: ApplyPlanArgs) -> Result<Outcome<Plan>> {
372 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
373 Outcome::Ok(repo) => repo,
374 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
375 };
376 if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
377 return Ok(Outcome::Fail(failure));
378 }
379 if let Some(refused) = may_plan(&a.actor, &repo) {
380 return Ok(refused);
381 }
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails382 if a.assign
383 && let Some(refused) = crate::mentions::refuse_job_token(&a.actor)
384 {
385 return Ok(refused);
386 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor387 let Some(row) = self
388 .plan_row(&a.id)
389 .await?
390 .filter(|row| row.repo_id == repo.id)
391 else {
392 return Ok(Outcome::fail(FailureCode::NotFound, "Plan not found."));
393 };
394 // Only whoever flips it from ready to applied opens the issues.
395 let claimed = self
396 .db
397 .prepare(
398 "UPDATE plans SET status = 'applied' WHERE id = ? AND status = 'ready'
399 RETURNING id AS value",
400 )
401 .bind(&[row.id.as_str().into()])?
402 .first::<crate::rows::ValueRow>(None)
403 .await?;
404 if claimed.is_none() {
405 return Ok(Outcome::fail(
406 FailureCode::Conflict,
407 "This plan is not waiting to be applied.",
408 ));
409 }
410
411 let mut plan: Plan = row.into();
412 // What the person kept, in the plan's order. A dependency on an
413 // issue they dropped is dropped with it.
414 let kept: Vec<usize> = match &a.keep {
415 Some(positions) => (0..plan.issues.len())
416 .filter(|index| positions.contains(&(*index as u32 + 1)))
417 .collect(),
418 None => (0..plan.issues.len()).collect(),
419 };
420 let queued_by = a
421 .assign
422 .then(|| serde_json::to_string(&a.actor))
423 .transpose()?;
424 let mut numbers: Vec<Option<u32>> = vec![None; plan.issues.len()];
425 // Whoever applies the plan opens its issues; g1t's agent applying
426 // one opens them as g1t, for the person it works for.
427 let (author, requested_by) = authorship(&a.actor, false);
428 for index in kept {
429 let planned = &plan.issues[index];
430 let blocked_by: Vec<u32> = planned
431 .depends_on
432 .iter()
433 .filter_map(|position| numbers.get(*position as usize - 1).copied().flatten())
434 .collect();
435 let number = self.next_number(&repo.id).await?;
436 let now = now_ms();
437 let timestamp = rfc3339(now);
438 let issue_id = new_id("iss", now);
439 self.db
440 .prepare(
441 "INSERT INTO issues
442 (id, repo_id, number, title, body, labels, checks, blocked_by, queued_by,
443 author_id, author_name, requested_by_id, requested_by_name, created_at, updated_at)
444 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
445 )
446 .bind(&[
447 issue_id.as_str().into(),
448 repo.id.as_str().into(),
449 number.into(),
450 planned.title.as_str().into(),
451 // What done looks like, in words, for the agent and
452 // reviewers; the branch's required checks gate the merge.
453 with_definition_of_done(&planned.body, &planned.done).into(),
454 serde_json::to_string(&planned.labels)?.into(),
455 "[]".into(),
456 serde_json::to_string(&blocked_by)?.into(),
457 queued_by.as_deref().map_or(JsValue::NULL, JsValue::from),
458 author.id.as_str().into(),
459 author.username.as_str().into(),
460 requested_by.as_ref().map_or(JsValue::NULL, |user| user.id.as_str().into()),
461 requested_by.as_ref().map_or(JsValue::NULL, |user| user.username.as_str().into()),
462 timestamp.as_str().into(),
463 timestamp.as_str().into(),
464 ])?
465 .run()
466 .await?;
467 if a.assign {
468 self.note(
469 &repo.id,
470 number,
471 (&a.actor.id, &a.actor.username),
472 &if blocked_by.is_empty() {
473 "queued this for g1t".to_owned()
474 } else {
475 format!(
476 "queued this for g1t, to start once {} {} merged",
477 blocked_by
478 .iter()
479 .map(|number| format!("#{number}"))
480 .collect::<Vec<_>>()
481 .join(", "),
482 if blocked_by.len() == 1 { "has" } else { "have" }
483 )
484 },
485 )
486 .await?;
487 }
488 self.publish(
489 "issue.opened",
490 &repo.id,
491 &a.actor,
492 IssueEvent {
493 issue_id,
494 repo_id: repo.id.clone(),
495 number,
496 author: Some((&author).into()),
497 requested_by: requested_by.as_ref().map(Into::into),
498 title: Some(planned.title.clone()),
499 ..IssueEvent::default()
500 },
501 )
502 .await?;
503 numbers[index] = Some(number);
504 }
505 for (issue, number) in plan.issues.iter_mut().zip(&numbers) {
506 issue.number = *number;
507 }
508 self.db
509 .prepare("UPDATE plans SET issues = ? WHERE id = ?")
510 .bind(&[
511 serde_json::to_string(&plan.issues)?.into(),
512 plan.id.as_str().into(),
513 ])?
514 .run()
515 .await?;
516 plan.status = PlanStatus::Applied;
517 Ok(Outcome::Ok(plan))
518 }
519
520 /// Queues an issue for a g1t agent, which needs the Write role, or
521 /// takes it out of the queue, as whoever may change the issue.
522 pub(crate) async fn queue_issue(&self, a: QueueIssueArgs) -> Result<Outcome<bool>> {
523 let issue = match self.manageable_issue(&a.actor, &a.repo, a.number).await? {
524 Outcome::Ok(issue) => issue,
525 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
526 };
527 if a.queued {
Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails528 if let Some(refused) = crate::mentions::refuse_job_token(&a.actor) {
529 return Ok(refused);
530 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor531 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
532 Outcome::Ok(repo) => repo,
533 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
534 };
535 if let Outcome::Fail(failure) = crate::allowed(Some(&a.actor), &repo, Capability::Run) {
536 return Ok(Outcome::Fail(failure));
537 }
538 }
539 let queued_by = a
540 .queued
541 .then(|| serde_json::to_string(&a.actor))
542 .transpose()?;
543 self.db
544 .prepare("UPDATE issues SET queued_by = ? WHERE id = ?")
545 .bind(&[
546 queued_by.as_deref().map_or(JsValue::NULL, JsValue::from),
547 issue.id.as_str().into(),
548 ])?
549 .run()
550 .await?;
551 Ok(Outcome::Ok(true))
552 }
553
554 /// Takes the issues that are waiting for a g1t agent and can be given
555 /// one now: open, queued, with nobody working on them, and with
556 /// everything they depend on closed. Oldest first, in one repository or
557 /// in all, and no more than leaves each repository with
558 /// `MAX_AGENTS_AT_WORK` agents making changes at once.
559 ///
560 /// Taking an issue takes it out of the queue, in one statement, so two
561 /// callers cannot both start an agent on it. A caller that then cannot
562 /// start one puts it back with `queue_issue`.
563 pub(crate) async fn ready_issues(&self, a: ReadyIssuesArgs) -> Result<Vec<ReadyIssue>> {
564 let rows = self
565 .db
566 .prepare(
567 "SELECT repo_id, number, queued_by FROM issues
568 WHERE state = 'open' AND queued_by IS NOT NULL
569 AND (?1 IS NULL OR repo_id = ?1)
570 AND NOT EXISTS (
571 SELECT 1 FROM pulls
572 WHERE pulls.issue_id = issues.id AND pulls.status IN ('draft', 'open'))
573 AND NOT EXISTS (
574 SELECT 1 FROM json_each(issues.blocked_by) AS blocker
575 JOIN issues AS earlier
576 ON earlier.repo_id = issues.repo_id AND earlier.number = blocker.value
577 WHERE earlier.state = 'open')
578 ORDER BY number LIMIT 50",
579 )
580 .bind(&[a.repo_id.map_or(JsValue::NULL, JsValue::from)])?
581 .all()
582 .await?
583 .results::<QueuedRow>()?;
584 let mut ready = Vec::new();
585 let mut room: HashMap<String, usize> = HashMap::new();
586 for row in rows {
587 let Ok(actor) = serde_json::from_str::<User>(&row.queued_by) else {
588 continue;
589 };
590 // How many more agents this repository has room for.
591 if !room.contains_key(&row.repo_id) {
592 // An archived or deleted repository's issues stay queued,
593 // untaken, for when it is writable again.
594 if !self.repo_active(&row.repo_id).await? {
595 room.insert(row.repo_id.clone(), 0);
596 continue;
597 }
598 let working = self
599 .db
600 .prepare(
601 "SELECT count(*) AS n FROM pulls
602 WHERE repo_id = ? AND managed = 1 AND status = 'draft'",
603 )
604 .bind(&[row.repo_id.as_str().into()])?
605 .first::<crate::rows::NumberRow>(None)
606 .await?
607 .map_or(0, |row| row.n as usize);
608 room.insert(
609 row.repo_id.clone(),
610 MAX_AGENTS_AT_WORK.saturating_sub(working),
611 );
612 }
613 let left = room.get_mut(&row.repo_id).expect("just inserted");
614 if *left == 0 {
615 continue;
616 }
617 let taken = self
618 .db
619 .prepare(
620 "UPDATE issues SET queued_by = NULL
621 WHERE repo_id = ? AND number = ? AND queued_by IS NOT NULL
622 RETURNING id AS value",
623 )
624 .bind(&[row.repo_id.as_str().into(), row.number.into()])?
625 .first::<crate::rows::ValueRow>(None)
626 .await?;
627 if taken.is_none() {
628 continue;
629 }
630 *left -= 1;
Queued issues are never dropped on the way to an agent, and a start that fails says why631 // Where the repository is now. Who may run agents there is the
632 // runner's question when it starts the issue, asked as whoever
633 // queued it; this only needs the address.
634 let path: Option<RepoPath> =
635 g1t_kit::call(&self.repos, "path_by_id", &PathByIdArgs { id: row.repo_id.clone() }).await?;
636 match path {
637 Some(repo) => ready.push(ReadyIssue { repo, number: row.number, actor }),
638 None => {
639 // Taken but not handed over: put it back, so it is not
640 // lost with nothing said.
641 worker::console_error!("ready_issues: no path for {}; issue #{} stays queued", row.repo_id, row.number);
642 self.db
643 .prepare("UPDATE issues SET queued_by = ? WHERE repo_id = ? AND number = ?")
644 .bind(&[row.queued_by.as_str().into(), row.repo_id.as_str().into(), row.number.into()])?
645 .run()
646 .await?;
647 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor648 }
649 }
650 Ok(ready)
651 }
652}
653
654/// Refuses whoever may not plan work in `repo`: the Write role, verified.
655fn may_plan<T>(actor: &User, repo: &Repo) -> Option<Outcome<T>> {
656 if !actor.verified {
657 return Some(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
658 }
659 match crate::allowed(Some(actor), repo, Capability::Run) {
660 Outcome::Ok(()) => None,
661 Outcome::Fail(failure) => Some(Outcome::Fail(failure)),
662 }
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 fn proposed(title: &str, depends_on: &[u32]) -> PlannedIssue {
670 PlannedIssue {
671 title: title.to_owned(),
672 body: " What to do. ".to_owned(),
673 labels: vec!["Feature".to_owned()],
674 done: vec![" The flag is documented in --help. ".to_owned(), String::new()],
675 files: vec!["src/lib.rs".to_owned()],
676 depends_on: depends_on.to_vec(),
677 number: None,
678 }
679 }
680
681 #[test]
682 fn a_proposal_is_trimmed_and_normalised() {
683 let issues = tidy(vec![proposed(" Add a flag ", &[])]);
684 assert_eq!(issues[0].title, "Add a flag");
685 assert_eq!(issues[0].body, "What to do.");
686 assert_eq!(issues[0].labels, ["feature"]);
687 assert_eq!(issues[0].done, ["The flag is documented in --help."]);
688 }
689
690 #[test]
691 fn an_issue_can_only_depend_on_earlier_ones() {
692 let issues = tidy(vec![
693 proposed("First", &[2]),
694 proposed("Second", &[1, 1, 2, 9]),
695 proposed("Third", &[2, 1]),
696 ]);
697 assert!(issues[0].depends_on.is_empty());
698 assert_eq!(issues[1].depends_on, [1]);
699 assert_eq!(issues[2].depends_on, [1, 2]);
700 }
701
702 #[test]
703 fn a_plan_is_bounded() {
704 let many = (0..30)
705 .map(|i| proposed(&format!("Issue {i}"), &[]))
706 .collect();
707 assert_eq!(tidy(many).len(), MAX_PLANNED_ISSUES);
708 }
709}

This file's history is long; its oldest lines are credited to the oldest commit read.