pr_01m47d15m3e54sn21z27rpy5n9/services/work/src/plans.rs

670 lines25,518 bytesCodeBlame

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.

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