forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
28.2 KiB
1//! Pages: CI runs (`/{owner}/{repo}/ci` and `/{owner}/{repo}/ci/runs/{number}`). Anyone who can read
2//! the repo sees its runs, their jobs and the config error of a run. Starting a manual run, cancelling,
3//! re-running and approving need Write, and the buttons are shown only to writers. Anyone else gets a
4//! 404 for a private repo and a refusal for a change. Every change notifies the job waker and syncs
5//! the commit status of each job it changed.
6
7use std::collections::HashMap;
8
9use forge_core::ci::{CiJob, CiRun, RunState};
10use forge_core::{Action, Denied, User, authorize};
11use forge_git::browse::{RefInfo, RefKind};
12use serde::Deserialize;
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
15use topcoat::Result;
16use topcoat::context::{Cx, app_context};
17use topcoat::router::content::Form;
18use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
19use topcoat::router::request::uri;
20use topcoat::router::{page, path_param, route};
21use topcoat::view::{View, component, view};
22
23use crate::auth::{current_user, require_csrf};
24use crate::i18n::{
25 self, Lang,
26 releases_ci::{self as g, K},
27};
28use crate::pages::refs::load_refs;
29use crate::pages::repo::repo_head;
30use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
31use crate::state::AppState;
32use crate::views::{alert, enc, now_unix, relative_time, shell, short_oid};
33
34path_param!(owner);
35path_param!(repo);
36path_param!(run_number);
37
38/// Runs per list page.
39const PER_PAGE: u32 = 25;
40/// Highest accepted `?page=` value. Anything else falls back to page 1.
41const MAX_PAGE: u32 = 1000;
42
43/// Notice words (the only text a `?notice=` query can show), their text and whether they are errors.
44const NOTICES: [(&str, K, bool); 8] = [
45 ("started", K::CiNoticeStarted, false),
46 ("cancelled", K::CiNoticeCancelled, false),
47 ("approved", K::CiNoticeApproved, false),
48 ("rerun", K::CiNoticeRerun, false),
49 ("finished", K::CiNoticeFinished, true),
50 ("not-waiting", K::CiNoticeNotWaiting, true),
51 ("no-branch", K::CiNoticeNoBranch, true),
52 ("no-manual", K::CiNoticeNoManual, true),
53];
54
55#[derive(Deserialize)]
56struct CsrfForm {
57 #[serde(default)]
58 csrf: String,
59}
60
61#[derive(Deserialize)]
62struct ManualForm {
63 #[serde(default)]
64 csrf: String,
65 #[serde(default)]
66 branch: String,
67}
68
69/// The viewer, if signed in.
70fn viewer_user(ctx: &RepoCtx) -> Option<&User> {
71 ctx.viewer.as_ref().map(|(user, _)| user)
72}
73
74/// Whether the viewer may change CI runs of this repo. Archived repos refuse.
75fn can_write(ctx: &RepoCtx) -> bool {
76 authorize(
77 viewer_user(ctx),
78 &ctx.repo,
79 ctx.role,
80 ctx.org_role,
81 Action::Write,
82 )
83 .is_ok()
84}
85
86/// Write access, or the refusal that matches why it is missing.
87fn require_write(ctx: &RepoCtx) -> Result<()> {
88 match authorize(
89 viewer_user(ctx),
90 &ctx.repo,
91 ctx.role,
92 ctx.org_role,
93 Action::Write,
94 ) {
95 Ok(()) => Ok(()),
96 Err(Denied::NotFound) => Err(not_found().into()),
97 Err(Denied::Forbidden) => Err(forbidden().into()),
98 }
99}
100
101fn title_of(ctx: &RepoCtx) -> String {
102 format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)
103}
104
105fn ci_href(title: &str) -> String {
106 format!("/{title}/ci")
107}
108
109/// The page of run `number`.
110pub fn run_href(title: &str, number: i64) -> String {
111 format!("/{title}/ci/runs/{number}")
112}
113
114/// The page of one job.
115pub fn job_href(title: &str, job_id: &str) -> String {
116 format!("/{title}/ci/jobs/{}", enc(job_id))
117}
118
119/// The notice from `?notice=<word>`. Anything else is ignored.
120fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> {
121 let query = uri(cx).query()?;
122 let word = form_urlencoded::parse(query.as_bytes())
123 .find(|(key, _)| key == "notice")
124 .map(|(_, value)| value.into_owned())?;
125 NOTICES
126 .iter()
127 .find(|(name, _, _)| *name == word)
128 .map(|(_, key, is_error)| (g::t(lang, *key), *is_error))
129}
130
131fn notice_redirect(href: &str, word: &str) -> SeeOther {
132 see_other(format!("{href}?notice={word}"))
133}
134
135/// The page number from `?page=`. Anything unreadable or out of range is page 1.
136fn page_from_query(cx: &Cx) -> u32 {
137 uri(cx)
138 .query()
139 .and_then(|query| {
140 form_urlencoded::parse(query.as_bytes())
141 .find(|(key, _)| key == "page")
142 .and_then(|(_, value)| value.parse::<u32>().ok())
143 })
144 .filter(|page| (1..=MAX_PAGE).contains(page))
145 .unwrap_or(1)
146}
147
148/// Unix seconds of an RFC 3339 stamp, if it parses.
149fn unix_of(stamp: &str) -> Option<i64> {
150 OffsetDateTime::parse(stamp, &Rfc3339)
151 .ok()
152 .map(|at| at.unix_timestamp())
153}
154
155/// "3 minutes ago" for a stamp. An unreadable stamp is shown as it is stored.
156fn age_text(lang: Lang, stamp: &str) -> String {
157 match unix_of(stamp) {
158 Some(unix) => relative_time(lang, unix, now_unix()),
159 None => stamp.to_owned(),
160 }
161}
162
163/// "1m 5s", or "" when the job never started. A running job counts up to now.
164fn duration_text(lang: Lang, started: Option<&str>, finished: Option<&str>) -> String {
165 let Some(start) = started.and_then(unix_of) else {
166 return String::new();
167 };
168 let end = finished.and_then(unix_of).unwrap_or_else(now_unix);
169 let secs = (end - start).max(0);
170 if secs >= 60 {
171 g::duration_minutes(lang, secs / 60, secs % 60)
172 } else {
173 g::duration_seconds(lang, secs)
174 }
175}
176
177/// Words for a state name, as the pages show it: `waiting_approval` reads "waiting approval".
178/// A state this build does not know is shown with its underscores as spaces.
179pub fn state_text(lang: Lang, state: &str) -> String {
180 let key = match state {
181 "waiting_approval" => K::StateWaitingApproval,
182 "queued" => K::StateQueued,
183 "running" => K::StateRunning,
184 "success" => K::StateSuccess,
185 "failure" => K::StateFailure,
186 "cancelled" => K::StateCancelled,
187 "error" => K::StateError,
188 "blocked" => K::StateBlocked,
189 "skipped" => K::StateSkipped,
190 other => return other.replace('_', " "),
191 };
192 g::t(lang, key).to_owned()
193}
194
195/// Whether a run has finished. Waiting for approval is not finished.
196pub fn run_finished(state: &RunState) -> bool {
197 matches!(
198 state,
199 RunState::Success | RunState::Failure | RunState::Cancelled | RunState::Error
200 )
201}
202
203/// Branch names of the repo, the default branch first.
204fn branch_choices(refs: &[RefInfo], default_branch: &str) -> Vec<String> {
205 let mut names: Vec<String> = refs
206 .iter()
207 .filter(|r| r.kind == RefKind::Branch)
208 .map(|r| r.name.clone())
209 .collect();
210 names.sort();
211 names.dedup();
212 if let Some(pos) = names.iter().position(|name| name == default_branch) {
213 let default = names.remove(pos);
214 names.insert(0, default);
215 }
216 names
217}
218
219/// The run with `number` in this repo, or 404.
220async fn run_of(state: &AppState, ctx: &RepoCtx, number: &str) -> Result<CiRun> {
221 let Ok(number) = number.parse::<i64>() else {
222 return Err(not_found().into());
223 };
224 match state.store.run_by_number(&ctx.repo.id, number).await? {
225 Some(run) => Ok(run),
226 None => Err(not_found().into()),
227 }
228}
229
230/// Wakes the job queue and syncs the commit status of every job whose state changed since `before`.
231/// A job missing from `before` (a fresh run) counts as changed.
232async fn after_change(state: &AppState, run_id: &str, before: &[CiJob]) -> Result<()> {
233 state.ci_waker.notify_one();
234 let Some(run) = state.store.run_by_id(run_id).await? else {
235 return Ok(());
236 };
237 for job in state.store.jobs_for_run(run_id).await? {
238 let changed = before
239 .iter()
240 .find(|old| old.id == job.id)
241 .is_none_or(|old| old.state != job.state);
242 if changed {
243 crate::ci::sync_status(state, &run, &job).await;
244 }
245 }
246 Ok(())
247}
248
249/// One run in the list, with its values already shown as text.
250struct RunRow {
251 href: String,
252 number: i64,
253 state: String,
254 state_class: String,
255 trigger: String,
256 git_ref: String,
257 commit: String,
258 actor: String,
259 when: String,
260}
261
262/// The run-list form that starts a manual run.
263struct ManualRun {
264 action: String,
265 csrf: String,
266 branches: Vec<String>,
267}
268
269/// A job row on the run page.
270struct JobRow {
271 href: String,
272 name: String,
273 state: String,
274 state_class: String,
275 duration: String,
276 runner: String,
277 message: String,
278}
279
280/// The run page's header values.
281struct RunView {
282 number: i64,
283 state: String,
284 state_class: String,
285 trigger: String,
286 git_ref: String,
287 commit: String,
288 actor: String,
289 when: String,
290 config_error: Option<String>,
291 action_base: String,
292 can_approve: bool,
293 can_cancel: bool,
294}
295
296/// Maps each user id in `ids` to a display name. A user that no longer exists shows as "deleted user".
297async fn usernames(
298 state: &AppState,
299 lang: Lang,
300 ids: impl Iterator<Item = &str>,
301) -> Result<HashMap<String, String>> {
302 let mut names = HashMap::new();
303 for id in ids {
304 if names.contains_key(id) {
305 continue;
306 }
307 let name = match state.store.user_by_id(id).await? {
308 Some(user) => user.username,
309 None => g::t(lang, K::DeletedUser).to_owned(),
310 };
311 names.insert(id.to_owned(), name);
312 }
313 Ok(names)
314}
315
316/// A small form that POSTs the CSRF token to `action`, with one button.
317#[component]
318async fn action_button(action: String, csrf: String, label: &'static str) -> Result<impl View> {
319 Ok(view! {
320 <form method="post" action=(action.as_str()) class="inline-form">
321 <input type="hidden" name="csrf" value=(csrf.as_str())>
322 <button type="submit">(label)</button>
323 </form>
324 })
325}
326
327/// The run list body: the manual run form for writers, the runs, and the pager.
328#[component]
329async fn runs_body(
330 head: RepoHead,
331 lang: Lang,
332 notice: Option<(&'static str, bool)>,
333 manual: Option<ManualRun>,
334 rows: Vec<RunRow>,
335 newer_href: Option<String>,
336 older_href: Option<String>,
337 page_label: String,
338) -> Result<impl View> {
339 Ok(view! {
340 repo_head(head: head)
341 if let Some((text, is_error)) = notice {
342 if is_error {
343 alert(message: text)
344 } else {
345 <section class="card notice" role="status">(text)</section>
346 }
347 }
348 if let Some(form) = manual {
349 <section class="card">
350 <h2>(g::t(lang, K::RunWorkflow))</h2>
351 <p class="muted small">(g::t(lang, K::RunWorkflowHelp))</p>
352 <form method="post" action=(form.action.as_str()) class="ci-manual-form">
353 <input type="hidden" name="csrf" value=(form.csrf.as_str())>
354 <label>
355 (g::t(lang, K::Branch)) " "
356 <select name="branch">
357 for branch in form.branches.iter() {
358 <option value=(branch.as_str())>(branch.as_str())</option>
359 }
360 </select>
361 </label>
362 <button type="submit">(g::t(lang, K::RunWorkflow))</button>
363 </form>
364 </section>
365 }
366 <section class="card">
367 <h2>(g::t(lang, K::Runs))</h2>
368 if rows.is_empty() {
369 <p class="muted">(g::t(lang, K::NoRunsYet))</p>
370 } else {
371 <div class="table-wrap">
372 <table class="table ci-runs">
373 <thead>
374 <tr>
375 <th>(g::t(lang, K::ColRun))</th>
376 <th>(g::t(lang, K::ColState))</th>
377 <th>(g::t(lang, K::ColTrigger))</th>
378 <th>(g::t(lang, K::ColRef))</th>
379 <th>(g::t(lang, K::ColCommit))</th>
380 <th>(g::t(lang, K::ColActor))</th>
381 <th>(g::t(lang, K::ColAge))</th>
382 </tr>
383 </thead>
384 <tbody>
385 for row in rows.iter() {
386 <tr>
387 <td><a href=(row.href.as_str())>"#" (row.number)</a></td>
388 <td><span class=(row.state_class.as_str())>(row.state.as_str())</span></td>
389 <td>(row.trigger.as_str())</td>
390 <td><code>(row.git_ref.as_str())</code></td>
391 <td><code>(row.commit.as_str())</code></td>
392 <td>(row.actor.as_str())</td>
393 <td>(row.when.as_str())</td>
394 </tr>
395 }
396 </tbody>
397 </table>
398 </div>
399 }
400 <nav class="pager">
401 if let Some(href) = newer_href {
402 <a href=(href.as_str())>(g::t(lang, K::Newer))</a>
403 }
404 <span class="muted small">(page_label.as_str())</span>
405 if let Some(href) = older_href {
406 <a href=(href.as_str())>(g::t(lang, K::Older))</a>
407 }
408 </nav>
409 </section>
410 })
411}
412
413/// Lists the repo's runs, newest first, 25 to a page. The manual run form is for writers only.
414#[page("/{owner}/{repo}/ci")]
415async fn runs_list_page(cx: &Cx) -> Result<impl View> {
416 let owner = path_param::<Owner>(cx);
417 let name = path_param::<Repo>(cx);
418 let ctx = load_repo(cx, owner, name).await?;
419 let lang = i18n::lang(cx, viewer_user(&ctx));
420 let state = app_context::<AppState>(cx);
421 let page = page_from_query(cx);
422 let (runs, total) = state.store.list_runs(&ctx.repo.id, page, PER_PAGE).await?;
423 let names = usernames(state, lang, runs.iter().map(|run| run.actor_id.as_str())).await?;
424 let rows: Vec<RunRow> = runs
425 .iter()
426 .map(|run| RunRow {
427 href: run_href(&title_of(&ctx), run.number),
428 number: run.number,
429 state: state_text(lang, run.state.as_str()),
430 state_class: format!("ci-state ci-{}", run.state.as_str()),
431 trigger: run.trigger.clone(),
432 git_ref: run.git_ref.clone(),
433 commit: short_oid(&run.commit_oid).to_owned(),
434 actor: names.get(&run.actor_id).cloned().unwrap_or_default(),
435 when: age_text(lang, &run.created_at),
436 })
437 .collect();
438 let last_page = total.div_ceil(u64::from(PER_PAGE)).max(1);
439 let base = ci_href(&title_of(&ctx));
440 let newer_href = (page > 1).then(|| format!("{base}?page={}", page - 1));
441 let older_href = (u64::from(page) < last_page).then(|| format!("{base}?page={}", page + 1));
442 let page_label = g::page_label(lang, page);
443 let manual = if can_write(&ctx) {
444 let csrf = match current_user(cx).await? {
445 Some((_, session)) => session.csrf_token.clone(),
446 None => String::new(),
447 };
448 let refs = load_refs(ctx.path.clone()).await?;
449 Some(ManualRun {
450 action: base.clone(),
451 csrf,
452 branches: branch_choices(&refs, &ctx.repo.default_branch),
453 })
454 } else {
455 None
456 };
457 let notice = notice_from_query(cx, lang);
458 let head = ctx.head(state, "ci").await?;
459 let title = format!("{} \u{b7} CI", title_of(&ctx));
460 Ok(view! {
461 shell(title: title.as_str(), viewer: ctx.viewer.as_ref(),
462 runs_body(head: head, lang: lang, notice: notice, manual: manual, rows: rows,
463 newer_href: newer_href, older_href: older_href, page_label: page_label))
464 })
465}
466
467/// Starts a manual run on a branch. Writers only. The branch must be one of the repo's branches.
468#[route(POST "/{owner}/{repo}/ci")]
469async fn manual_run_post(cx: &Cx, Form(form): Form<ManualForm>) -> Result<SeeOther> {
470 let owner = path_param::<Owner>(cx);
471 let name = path_param::<Repo>(cx);
472 let ctx = load_repo(cx, owner, name).await?;
473 let (user, _) = require_csrf(cx, &form.csrf).await?;
474 require_write(&ctx)?;
475 let state = app_context::<AppState>(cx);
476 let title = title_of(&ctx);
477 let branch = form.branch.trim();
478 let refs = load_refs(ctx.path.clone()).await?;
479 if !branch_choices(&refs, &ctx.repo.default_branch)
480 .iter()
481 .any(|choice| choice == branch)
482 {
483 return Ok(notice_redirect(&ci_href(&title), "no-branch"));
484 }
485 let run = match crate::ci::manual_run(state, &ctx.repo, branch, &user.id).await {
486 Ok(run) => run,
487 Err(forge_core::Error::Invalid(_)) => {
488 return Ok(notice_redirect(&ci_href(&title), "no-manual"));
489 }
490 Err(e) => return Err(e.into()),
491 };
492 after_change(state, &run.id, &[]).await?;
493 Ok(see_other(run_href(&title, run.number)))
494}
495
496/// The header values of a run for its page.
497async fn run_view(
498 state: &AppState,
499 ctx: &RepoCtx,
500 run: &CiRun,
501 writer: bool,
502 lang: Lang,
503) -> Result<RunView> {
504 let names = usernames(state, lang, std::iter::once(run.actor_id.as_str())).await?;
505 let title = title_of(ctx);
506 let action_base = run_href(&title, run.number);
507 Ok(RunView {
508 number: run.number,
509 state: state_text(lang, run.state.as_str()),
510 state_class: format!("ci-state ci-{}", run.state.as_str()),
511 trigger: run.trigger.clone(),
512 git_ref: run.git_ref.clone(),
513 commit: run.commit_oid.clone(),
514 actor: names.get(&run.actor_id).cloned().unwrap_or_default(),
515 when: age_text(lang, &run.created_at),
516 config_error: run.config_error.clone(),
517 can_approve: writer && run.state == RunState::WaitingApproval,
518 can_cancel: writer && !run_finished(&run.state),
519 action_base,
520 })
521}
522
523/// The job rows of a run page. Runner names come from the runner list.
524fn job_rows(
525 title: &str,
526 jobs: &[CiJob],
527 runners: &HashMap<String, String>,
528 lang: Lang,
529) -> Vec<JobRow> {
530 jobs.iter()
531 .map(|job| JobRow {
532 href: job_href(title, &job.id),
533 name: job.name.clone(),
534 state: state_text(lang, job.state.as_str()),
535 state_class: format!("ci-state ci-{}", job.state.as_str()),
536 duration: duration_text(lang, job.started_at.as_deref(), job.finished_at.as_deref()),
537 runner: job
538 .runner_id
539 .as_ref()
540 .and_then(|id| runners.get(id).cloned())
541 .unwrap_or_default(),
542 message: job.message.clone().unwrap_or_default(),
543 })
544 .collect()
545}
546
547/// The run page body: the header, the buttons for writers, the config error, and the jobs.
548#[component]
549async fn run_body(
550 head: RepoHead,
551 lang: Lang,
552 notice: Option<(&'static str, bool)>,
553 run: RunView,
554 jobs: Vec<JobRow>,
555 writer_csrf: Option<String>,
556 refresh: bool,
557) -> Result<impl View> {
558 Ok(view! {
559 repo_head(head: head)
560 if refresh {
561 <meta http-equiv="refresh" content="3">
562 }
563 if let Some((text, is_error)) = notice {
564 if is_error {
565 alert(message: text)
566 } else {
567 <section class="card notice" role="status">(text)</section>
568 }
569 }
570 <section class="card">
571 <h2>(g::t(lang, K::RunNumber)) (run.number)</h2>
572 <p class="muted small">
573 <span class=(run.state_class.as_str())>(run.state.as_str())</span>
574 " \u{b7} " (run.trigger.as_str())
575 " \u{b7} " <code>(run.git_ref.as_str())</code>
576 " \u{b7} " <code>(short_oid(&run.commit).to_owned())</code>
577 if !run.actor.is_empty() {
578 " \u{b7} " (run.actor.as_str())
579 }
580 " \u{b7} " (run.when.as_str())
581 </p>
582 if let Some(message) = run.config_error.as_ref() {
583 <pre class="ci-config-error">(message.as_str())</pre>
584 }
585 if let Some(csrf) = writer_csrf {
586 <div class="ci-actions">
587 if run.can_approve {
588 action_button(action: format!("{}/approve", run.action_base), csrf: csrf.clone(), label: g::t(lang, K::ApproveAndRun))
589 }
590 if run.can_cancel {
591 action_button(action: format!("{}/cancel", run.action_base), csrf: csrf.clone(), label: g::t(lang, K::Cancel))
592 }
593 action_button(action: format!("{}/rerun", run.action_base), csrf: csrf, label: g::t(lang, K::Rerun))
594 </div>
595 }
596 </section>
597 <section class="card">
598 <h2>(g::t(lang, K::Jobs))</h2>
599 if jobs.is_empty() {
600 <p class="muted">(g::t(lang, K::NoJobs))</p>
601 } else {
602 <div class="table-wrap">
603 <table class="table ci-jobs">
604 <thead>
605 <tr>
606 <th>(g::t(lang, K::ColJob))</th>
607 <th>(g::t(lang, K::ColState))</th>
608 <th>(g::t(lang, K::ColDuration))</th>
609 <th>(g::t(lang, K::ColRunner))</th>
610 <th>(g::t(lang, K::ColMessage))</th>
611 </tr>
612 </thead>
613 <tbody>
614 for job in jobs.iter() {
615 <tr>
616 <td><a href=(job.href.as_str())>(job.name.as_str())</a></td>
617 <td><span class=(job.state_class.as_str())>(job.state.as_str())</span></td>
618 <td>(job.duration.as_str())</td>
619 <td>(job.runner.as_str())</td>
620 <td>(job.message.as_str())</td>
621 </tr>
622 }
623 </tbody>
624 </table>
625 </div>
626 }
627 </section>
628 })
629}
630
631/// One run: its state, the buttons for writers, the config error and its jobs.
632#[page("/{owner}/{repo}/ci/runs/{run_number}")]
633async fn run_page(cx: &Cx) -> Result<impl View> {
634 let owner = path_param::<Owner>(cx);
635 let name = path_param::<Repo>(cx);
636 let number = path_param::<RunNumber>(cx).to_owned();
637 let ctx = load_repo(cx, owner, name).await?;
638 let lang = i18n::lang(cx, viewer_user(&ctx));
639 let state = app_context::<AppState>(cx);
640 let run = run_of(state, &ctx, &number).await?;
641 let writer = can_write(&ctx);
642 let writer_csrf = if writer {
643 match current_user(cx).await? {
644 Some((_, session)) => Some(session.csrf_token.clone()),
645 None => None,
646 }
647 } else {
648 None
649 };
650 let jobs = state.store.jobs_for_run(&run.id).await?;
651 let runners: HashMap<String, String> = state
652 .store
653 .list_runners()
654 .await?
655 .into_iter()
656 .map(|runner| (runner.id, runner.name))
657 .collect();
658 let title = title_of(&ctx);
659 let rows = job_rows(&title, &jobs, &runners, lang);
660 let refresh = !run_finished(&run.state) && run.state != RunState::WaitingApproval;
661 let notice = notice_from_query(cx, lang);
662 let header = run_view(state, &ctx, &run, writer, lang).await?;
663 let head = ctx.head(state, "ci").await?;
664 let page_title = format!("{title} \u{b7} CI #{}", run.number);
665 Ok(view! {
666 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
667 run_body(head: head, lang: lang, notice: notice, run: header, jobs: rows,
668 writer_csrf: writer_csrf, refresh: refresh))
669 })
670}
671
672/// Cancels a run that has not finished. Queued and blocked jobs are cancelled at once; running jobs
673/// get a cancel request that their runner sees on its next heartbeat.
674#[route(POST "/{owner}/{repo}/ci/runs/{run_number}/cancel")]
675async fn run_cancel(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
676 let owner = path_param::<Owner>(cx);
677 let name = path_param::<Repo>(cx);
678 let number = path_param::<RunNumber>(cx).to_owned();
679 let ctx = load_repo(cx, owner, name).await?;
680 require_csrf(cx, &form.csrf).await?;
681 require_write(&ctx)?;
682 let state = app_context::<AppState>(cx);
683 let run = run_of(state, &ctx, &number).await?;
684 let href = run_href(&title_of(&ctx), run.number);
685 if run_finished(&run.state) {
686 return Ok(notice_redirect(&href, "finished"));
687 }
688 let before = state.store.jobs_for_run(&run.id).await?;
689 state.store.request_cancel(&run.id).await?;
690 after_change(state, &run.id, &before).await?;
691 Ok(notice_redirect(&href, "cancelled"))
692}
693
694/// Approves a run that waits for approval: its jobs are queued. Anything else is refused.
695#[route(POST "/{owner}/{repo}/ci/runs/{run_number}/approve")]
696async fn run_approve(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
697 let owner = path_param::<Owner>(cx);
698 let name = path_param::<Repo>(cx);
699 let number = path_param::<RunNumber>(cx).to_owned();
700 let ctx = load_repo(cx, owner, name).await?;
701 require_csrf(cx, &form.csrf).await?;
702 require_write(&ctx)?;
703 let state = app_context::<AppState>(cx);
704 let run = run_of(state, &ctx, &number).await?;
705 let href = run_href(&title_of(&ctx), run.number);
706 if run.state != RunState::WaitingApproval {
707 return Ok(notice_redirect(&href, "not-waiting"));
708 }
709 let before = state.store.jobs_for_run(&run.id).await?;
710 state.store.approve_run(&run.id).await?;
711 after_change(state, &run.id, &before).await?;
712 Ok(notice_redirect(&href, "approved"))
713}
714
715/// Starts a new run with the same commit, ref, trigger and job specs, then opens it.
716#[route(POST "/{owner}/{repo}/ci/runs/{run_number}/rerun")]
717async fn run_rerun(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
718 let owner = path_param::<Owner>(cx);
719 let name = path_param::<Repo>(cx);
720 let number = path_param::<RunNumber>(cx).to_owned();
721 let ctx = load_repo(cx, owner, name).await?;
722 let (user, _) = require_csrf(cx, &form.csrf).await?;
723 require_write(&ctx)?;
724 let state = app_context::<AppState>(cx);
725 let run = run_of(state, &ctx, &number).await?;
726 let new_run = state.store.rerun(&run.id, &user.id).await?;
727 after_change(state, &new_run.id, &[]).await?;
728 Ok(notice_redirect(
729 &run_href(&title_of(&ctx), new_run.number),
730 "rerun",
731 ))
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 #[test]
739 fn durations_read_well() {
740 assert_eq!(
741 duration_text(
742 Lang::En,
743 Some("2026-10-01T10:00:00.000000000Z"),
744 Some("2026-10-01T10:01:05.000000000Z")
745 ),
746 "1m 5s"
747 );
748 assert_eq!(
749 duration_text(
750 Lang::En,
751 Some("2026-10-01T10:00:00.000000000Z"),
752 Some("2026-10-01T10:00:09.000000000Z")
753 ),
754 "9s"
755 );
756 assert_eq!(duration_text(Lang::En, None, None), "");
757 }
758
759 #[test]
760 fn state_names_read_as_words() {
761 assert_eq!(state_text(Lang::En, "waiting_approval"), "waiting approval");
762 assert_eq!(state_text(Lang::En, "success"), "success");
763 assert_eq!(state_text(Lang::Tr, "waiting_approval"), "onay bekliyor");
764 assert_eq!(state_text(Lang::Tr, "queued"), "sırada");
765 }
766
767 #[test]
768 fn finished_runs_are_the_terminal_ones() {
769 assert!(run_finished(&RunState::Success));
770 assert!(run_finished(&RunState::Cancelled));
771 assert!(!run_finished(&RunState::WaitingApproval));
772 assert!(!run_finished(&RunState::Running));
773 }
774
775 #[test]
776 fn default_branch_comes_first() {
777 let refs = vec![
778 RefInfo {
779 name: "dev".into(),
780 full_name: "refs/heads/dev".into(),
781 target: "a".into(),
782 kind: RefKind::Branch,
783 },
784 RefInfo {
785 name: "main".into(),
786 full_name: "refs/heads/main".into(),
787 target: "b".into(),
788 kind: RefKind::Branch,
789 },
790 RefInfo {
791 name: "v1".into(),
792 full_name: "refs/tags/v1".into(),
793 target: "c".into(),
794 kind: RefKind::Tag,
795 },
796 ];
797 assert_eq!(branch_choices(&refs, "main"), vec!["main", "dev"]);
798 }
799}