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
719 linesCodeBlameRaw
1//! Reading: workflows, runs (any attempt), a run's jobs, a job's log and
2//! its steps' summaries.
3
4use g1t_actions::workflow::{self, Severity};
5use g1t_contracts::access::Capability;
6use g1t_contracts::checks::{ActionsChecksArgs, MAX_COMMITS};
7use g1t_contracts::actions::{
8 Annotation, Job, JobLog, JobLogText, JobLogTextArgs, JobSummary, LogChunk, LogsArgs, MAX_RUN_LOG_BYTES, RunArgs, RunAttempt, RunDetail,
9 RunLogsArgs, RunsArgs, SetWorkflowEnabledArgs, StepState, StepSummary, SummariesArgs, Workflow, WorkflowArgs, WorkflowDetail, WorkflowNote, WorkflowRun,
10 WorkflowsArgs,
11};
12use g1t_contracts::{FailureCode, Outcome};
13use serde::Deserialize;
14use worker::Result;
15
16use crate::plan::{JobRow, RunRow};
17use crate::sync::WorkflowRow;
18use crate::{Actions, check, fail};
19
20const RUNS_SHOWN: u32 = 50;
21
22fn notes(source: &str) -> Vec<WorkflowNote> {
23 workflow::parse(source)
24 .map(|w| {
25 w.notes
26 .into_iter()
27 .map(|note| WorkflowNote {
28 severity: match note.severity {
29 Severity::Info => "info",
30 Severity::Warning => "warning",
31 Severity::Unsupported => "unsupported",
32 }
33 .to_owned(),
34 job: note.job,
35 message: note.message,
36 })
37 .collect()
38 })
39 .unwrap_or_default()
40}
41
42/// A job of an earlier attempt, as it ended (migration 0009).
43#[derive(Clone, Deserialize)]
44pub struct AttemptJobRow {
45 pub id: String,
46 pub run_id: String,
47 pub log_id: String,
48 pub key: String,
49 pub name: String,
50 pub needs: String,
51 pub status: String,
52 pub conclusion: Option<String>,
53 pub steps: String,
54 pub annotations: String,
55 pub reason: Option<String>,
56 pub environment: Option<String>,
57 pub labels: Option<String>,
58 pub runner_name: Option<String>,
59 pub started_at: Option<String>,
60 pub finished_at: Option<String>,
61}
62
63fn attempt_job_view(row: AttemptJobRow) -> Job {
64 Job {
65 needs: serde_json::from_str(&row.needs).unwrap_or_default(),
66 id: row.id,
67 run_id: row.run_id,
68 key: row.key,
69 name: row.name,
70 status: row.status,
71 conclusion: row.conclusion,
72 steps: serde_json::from_str::<Vec<StepState>>(&row.steps).unwrap_or_default(),
73 annotations: serde_json::from_str::<Vec<Annotation>>(&row.annotations).unwrap_or_default(),
74 reason: row.reason,
75 started_at: row.started_at,
76 finished_at: row.finished_at,
77 environment: row.environment,
78 self_hosted: row.labels.is_some(),
79 runner: row.runner_name,
80 cancelling: false,
81 environment_url: None,
82 uses: None,
83 }
84}
85
86/// An earlier attempt of a run, as it ended.
87#[derive(Clone, Deserialize)]
88struct AttemptRow {
89 attempt: u64,
90 conclusion: Option<String>,
91 actor: Option<String>,
92 debug: u32,
93 started_at: Option<String>,
94 finished_at: Option<String>,
95}
96
97/// One attempt's jobs, each with where its logs and summary are kept.
98struct AttemptJobs {
99 jobs: Vec<Job>,
100 log_ids: Vec<String>,
101}
102
103fn job_view(row: JobRow) -> Job {
104 let needs = row.needs();
105 let cancelling = row.cancel_requested_at.is_some() && row.status != "completed";
106 let uses = row.call().filter(|call| call["role"] == "caller").and_then(|call| call["path"].as_str().map(str::to_owned));
107 Job {
108 uses,
109 environment_url: None,
110 cancelling,
111 id: row.id,
112 run_id: row.run_id,
113 key: row.key,
114 name: row.name,
115 needs,
116 status: row.status,
117 conclusion: row.conclusion,
118 steps: serde_json::from_str::<Vec<StepState>>(&row.steps).unwrap_or_default(),
119 annotations: serde_json::from_str::<Vec<Annotation>>(&row.annotations).unwrap_or_default(),
120 reason: row.reason,
121 started_at: row.started_at,
122 finished_at: row.finished_at,
123 environment: row.environment,
124 self_hosted: row.labels.is_some(),
125 runner: row.runner_name,
126 }
127}
128
129/// The workflow `wanted` names: its id, its path, its file name, or its
130/// name (any case), in that order of preference.
131fn pick_workflow<'a>(rows: &'a [WorkflowRow], wanted: &str) -> Option<&'a WorkflowRow> {
132 let wanted = wanted.trim();
133 let file = wanted.trim_start_matches(".g1t/workflows/");
134 rows.iter()
135 .find(|row| row.id == wanted || row.path == wanted)
136 .or_else(|| rows.iter().find(|row| row.path == format!("{}/{file}", workflow::FOLDER)))
137 .or_else(|| rows.iter().find(|row| row.path.rsplit('/').next() == Some(file)))
138 .or_else(|| rows.iter().find(|row| row.name.eq_ignore_ascii_case(wanted)))
139}
140
141impl Actions {
142 async fn summary(&self, row: &WorkflowRow) -> Result<Workflow> {
143 let last_run = self
144 .db
145 .prepare("SELECT * FROM runs WHERE workflow_id = ? ORDER BY id DESC LIMIT 1")
146 .bind(&[row.id.as_str().into()])?
147 .first::<RunRow>(None)
148 .await?
149 .map(|run| run.summary());
150 let parsed = workflow::parse(&row.source).ok();
151 let managed_by = if g1t_actions::managed::is_managed(&row.path) {
152 let workspace = row.repo.split_once('/').map_or(row.repo.as_str(), |(workspace, _)| workspace);
153 self.managed_by(workspace, &row.path, &row.source).await?
154 } else {
155 None
156 };
157 Ok(Workflow {
158 id: row.id.clone(),
159 path: row.path.clone(),
160 name: row.name.clone(),
161 events: serde_json::from_str(&row.events).unwrap_or_default(),
162 state: row.state.clone(),
163 error: row.error.clone(),
164 notes: notes(&row.source),
165 dispatch: parsed
166 .as_ref()
167 .filter(|_| row.error.is_none())
168 .and_then(|w| w.trigger("workflow_dispatch"))
169 .map(|t| serde_json::Value::Object(t.inputs.clone())),
170 last_run,
171 managed_by,
172 })
173 }
174
175 pub async fn workflows(&self, a: WorkflowsArgs) -> Result<Outcome<Vec<Workflow>>> {
176 let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
177 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
178 };
179 if !self.synced(&repo.id).await?
180 && let Some(ws) = self.workspace_actor(&repo.namespace).await?
181 {
182 self.sync(&repo, &ws).await?;
183 }
184 // A file that is gone stays listed while it has runs, unless one in
185 // the folder now has its name: then it would read as a second copy.
186 // Files outside the folder are from before g1t stopped reading
187 // `.github`; their runs stay under All workflows. Managed
188 // workflows (managed.rs) are listed with the folder's.
189 let rows = self
190 .db
191 .prepare(
192 "SELECT * FROM workflows w WHERE w.repo_id = ?1 AND (w.path LIKE ?2 OR w.path LIKE 'managed/%')
193 AND (w.error IS NULL OR w.error NOT LIKE 'Its file is%'
194 OR (w.id IN (SELECT workflow_id FROM runs WHERE repo_id = ?1)
195 AND NOT EXISTS (SELECT 1 FROM workflows o WHERE o.repo_id = ?1 AND o.id <> w.id
196 AND o.name = w.name AND (o.error IS NULL OR o.error NOT LIKE 'Its file is%'))))
197 ORDER BY w.name",
198 )
199 .bind(&[repo.id.as_str().into(), format!("{}/%", g1t_actions::workflow::FOLDER).into()])?
200 .all()
201 .await?
202 .results::<WorkflowRow>()?;
203 let mut out = Vec::with_capacity(rows.len());
204 for row in &rows {
205 out.push(self.summary(row).await?);
206 }
207 Ok(Outcome::Ok(out))
208 }
209
210 /// `workflow`: one workflow, by its id, file name, path or name, with its
211 /// file as of a branch or tag.
212 pub async fn workflow(&self, a: WorkflowArgs) -> Result<Outcome<WorkflowDetail>> {
213 let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
214 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
215 };
216 let ws = self.workspace_actor(&repo.namespace).await?;
217 if !self.synced(&repo.id).await?
218 && let Some(ws) = &ws
219 {
220 self.sync(&repo, ws).await?;
221 }
222 let rows = self
223 .db
224 .prepare("SELECT * FROM workflows WHERE repo_id = ? ORDER BY name")
225 .bind(&[repo.id.as_str().into()])?
226 .all()
227 .await?
228 .results::<WorkflowRow>()?;
229 let Some(row) = pick_workflow(&rows, &a.workflow) else {
230 return Ok(fail(FailureCode::NotFound, format!("There is no workflow {} in {}/{}.", a.workflow.trim(), repo.namespace, repo.name)));
231 };
232 let workflow = self.summary(row).await?;
233 let (git_ref, source) = match a.git_ref.as_deref().map(str::trim).filter(|r| !r.is_empty()) {
234 None => (repo.default_branch.clone(), row.source.clone()),
235 Some(wanted) => {
236 let Some(ws) = &ws else {
237 return Ok(fail(FailureCode::NotFound, "There is no such workspace."));
238 };
239 let short = wanted.trim_start_matches("refs/heads/").trim_start_matches("refs/tags/");
240 let read = self.read_for(&repo, ws, Some(short)).await?;
241 if read.head.is_none() {
242 return Ok(fail(FailureCode::NotFound, format!("There is no branch or tag called {short}.")));
243 }
244 let Some(file) = read.files.into_iter().find(|file| file.path == row.path) else {
245 return Ok(fail(FailureCode::NotFound, format!("{} is not on {short}.", row.path)));
246 };
247 (short.to_owned(), file.source)
248 }
249 };
250 Ok(Outcome::Ok(WorkflowDetail { workflow, git_ref, source }))
251 }
252
253 pub async fn runs(&self, a: RunsArgs) -> Result<Outcome<Vec<WorkflowRun>>> {
254 let Some(repo) = self.visible_repo(&a.repo, &a.viewer).await? else {
255 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
256 };
257 let mut sql = "SELECT * FROM runs WHERE repo_id = ?".to_owned();
258 let mut binds: Vec<worker::wasm_bindgen::JsValue> = vec![repo.id.as_str().into()];
259 if let Some(workflow) = &a.workflow {
260 sql.push_str(" AND (workflow_id = ? OR path = ? OR path = ?)");
261 binds.push(workflow.as_str().into());
262 binds.push(workflow.as_str().into());
263 binds.push(format!("{}/{workflow}", g1t_actions::workflow::FOLDER).into());
264 }
265 if let Some(branch) = &a.branch {
266 sql.push_str(" AND (git_ref = ? OR json_extract(info, '$.headRef') = ?)");
267 binds.push(format!("refs/heads/{branch}").into());
268 binds.push(branch.as_str().into());
269 }
270 if let Some(event) = &a.event {
271 sql.push_str(" AND event = ?");
272 binds.push(event.as_str().into());
273 }
274 if let Some(pull) = a.pull {
275 sql.push_str(" AND pull = ?");
276 binds.push(pull.into());
277 }
278 if let Some(sha) = &a.sha {
279 sql.push_str(" AND sha = ?");
280 binds.push(sha.as_str().into());
281 }
282 // GitHub's `status` takes a status or a conclusion.
283 if let Some(status) = a.status.as_deref().map(str::trim).filter(|status| !status.is_empty()) {
284 sql.push_str(" AND (status = ? OR conclusion = ?)");
285 binds.push(status.into());
286 binds.push(status.into());
287 }
288 // When they were created: kept as RFC 3339 to the millisecond, so a
289 // time written the same way compares as text.
290 for (bound, op) in [(&a.since, ">="), (&a.until, "<")] {
291 let Some(text) = bound.as_deref().map(str::trim).filter(|text| !text.is_empty()) else { continue };
292 let Some(at) = g1t_contracts::time::parse_rfc3339(text) else {
293 return Ok(fail(FailureCode::Invalid, "since and until must be RFC 3339 times, such as 2026-10-14T00:00:00Z."));
294 };
295 sql.push_str(&format!(" AND created_at {op} ?"));
296 binds.push(g1t_contracts::time::rfc3339(at).into());
297 }
298 sql.push_str(" ORDER BY id DESC LIMIT ?");
299 binds.push(a.limit.unwrap_or(RUNS_SHOWN).clamp(1, 100).into());
300 let rows = self.db.prepare(sql).bind(&binds)?.all().await?.results::<RunRow>()?;
301 Ok(Outcome::Ok(rows.iter().map(RunRow::summary).collect()))
302 }
303
304 /// Every attempt of a run, oldest first, the current one last.
305 async fn attempts_of(&self, run: &RunRow) -> Result<Vec<RunAttempt>> {
306 let mut attempts: Vec<RunAttempt> = self
307 .db
308 .prepare("SELECT attempt, conclusion, actor, debug, started_at, finished_at FROM run_attempts WHERE run_id = ? ORDER BY attempt")
309 .bind(&[run.id.as_str().into()])?
310 .all()
311 .await?
312 .results::<AttemptRow>()?
313 .into_iter()
314 .filter(|row| row.attempt < run.attempt)
315 .map(|row| RunAttempt {
316 attempt: row.attempt,
317 status: "completed".to_owned(),
318 conclusion: row.conclusion,
319 actor: row.actor,
320 debug: row.debug != 0,
321 started_at: row.started_at,
322 finished_at: row.finished_at,
323 })
324 .collect();
325 attempts.push(RunAttempt {
326 attempt: run.attempt,
327 status: run.status.clone(),
328 conclusion: run.conclusion.clone().filter(|_| run.status == "completed"),
329 actor: run.triggering_actor.clone().or_else(|| run.actor.clone()),
330 debug: run.debug != 0,
331 started_at: run.started_at.clone(),
332 finished_at: run.finished_at.clone(),
333 });
334 Ok(attempts)
335 }
336
337 /// The jobs of one attempt: the live ones for the current attempt, the
338 /// kept ones for an earlier attempt. None for an attempt it never had.
339 async fn attempt_jobs(&self, run: &RunRow, attempt: Option<u64>) -> Result<Option<AttemptJobs>> {
340 match attempt.filter(|n| *n != run.attempt) {
341 None => {
342 // A job that deploys: where to, as its `environment.url` reads.
343 let jobs: Vec<Job> = self
344 .job_rows(&run.id)
345 .await?
346 .into_iter()
347 .map(|row| {
348 let environment_url = row.environment.as_ref().and_then(|_| crate::plan::deploys_to(run, &row)).and_then(|env| env.url);
349 Job { environment_url, ..job_view(row) }
350 })
351 .collect();
352 let log_ids = jobs.iter().map(|job| job.id.clone()).collect();
353 Ok(Some(AttemptJobs { jobs, log_ids }))
354 }
355 Some(n) if n == 0 || n > run.attempt => Ok(None),
356 Some(n) => {
357 let rows = self
358 .db
359 .prepare("SELECT * FROM job_attempts WHERE run_id = ? AND attempt = ? ORDER BY rowid")
360 .bind(&[run.id.as_str().into(), (n as f64).into()])?
361 .all()
362 .await?
363 .results::<AttemptJobRow>()?;
364 let log_ids = rows.iter().map(|row| row.log_id.clone()).collect();
365 Ok(Some(AttemptJobs { jobs: rows.into_iter().map(attempt_job_view).collect(), log_ids }))
366 }
367 }
368 }
369
370 pub async fn run(&self, a: RunArgs) -> Result<Outcome<RunDetail>> {
371 if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
372 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
373 }
374 let run = check!(self.run_in(&a.repo, &a.id).await?);
375 let Some(AttemptJobs { jobs, .. }) = self.attempt_jobs(&run, a.attempt).await? else {
376 return Ok(fail(FailureCode::NotFound, format!("This run has no attempt {}.", a.attempt.unwrap_or_default())));
377 };
378 let attempts = self.attempts_of(&run).await?;
379 let mut summary = run.summary();
380 // An earlier attempt: as it ended, and nothing waits for it.
381 if let Some(earlier) = a.attempt.filter(|n| *n != run.attempt).and_then(|n| attempts.iter().find(|at| at.attempt == n)) {
382 summary.attempt = earlier.attempt;
383 summary.status = "completed".to_owned();
384 summary.conclusion = earlier.conclusion.clone();
385 summary.started_at = earlier.started_at.clone();
386 summary.finished_at = earlier.finished_at.clone();
387 return Ok(Outcome::Ok(RunDetail {
388 notes: notes(&run.source),
389 run: summary,
390 jobs,
391 approval: run.approval(),
392 pending_deployments: Vec::new(),
393 attempts,
394 }));
395 }
396 let pending_deployments = self.pending_for(&run, &a.viewer).await?;
397 summary.status = shown_status(&summary.status, jobs.iter().map(|job| job.status.as_str()));
398 Ok(Outcome::Ok(RunDetail {
399 notes: notes(&run.source),
400 run: summary,
401 jobs,
402 approval: run.approval(),
403 pending_deployments,
404 attempts,
405 }))
406 }
407
408 /// A job of the repository's runs by the id a run's page gives it: a
409 /// live job, or one of an earlier attempt. Its name, steps, where its
410 /// logs and summary are kept, and whether it has finished.
411 async fn job_for_log(&self, repo: &g1t_contracts::repos::RepoPath, id: &str) -> Result<Option<(Job, String, bool)>> {
412 let full = format!("{}/{}", repo.namespace, repo.name);
413 let live = self
414 .db
415 .prepare("SELECT jobs.* FROM jobs JOIN runs ON runs.id = jobs.run_id WHERE jobs.id = ? AND lower(runs.repo) = lower(?)")
416 .bind(&[id.into(), full.as_str().into()])?
417 .first::<JobRow>(None)
418 .await?;
419 if let Some(job) = live {
420 let done = job.status == "completed";
421 let view = job_view(job);
422 let log_id = view.id.clone();
423 return Ok(Some((view, log_id, done)));
424 }
425 let kept = self
426 .db
427 .prepare(
428 "SELECT job_attempts.* FROM job_attempts JOIN runs ON runs.id = job_attempts.run_id
429 WHERE job_attempts.id = ? AND lower(runs.repo) = lower(?)",
430 )
431 .bind(&[id.into(), full.as_str().into()])?
432 .first::<AttemptJobRow>(None)
433 .await?;
434 Ok(kept.map(|row| {
435 let log_id = row.log_id.clone();
436 (attempt_job_view(row), log_id, true)
437 }))
438 }
439
440 async fn log_chunks(&self, log_id: &str, after: u64, limit: u32) -> Result<Vec<LogChunk>> {
441 #[derive(Deserialize)]
442 struct Row {
443 seq: u64,
444 step: u32,
445 text: String,
446 }
447 Ok(self
448 .db
449 .prepare("SELECT seq, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT ?")
450 .bind(&[log_id.into(), (after as f64).into(), limit.into()])?
451 .all()
452 .await?
453 .results::<Row>()?
454 .into_iter()
455 .map(|row| LogChunk { seq: row.seq, step: row.step, text: row.text })
456 .collect())
457 }
458
459 /// Every chunk of a job's log, a page at a time.
460 async fn whole_log(&self, log_id: &str) -> Result<Vec<LogChunk>> {
461 let mut chunks: Vec<LogChunk> = Vec::new();
462 loop {
463 let after = chunks.last().map_or(0, |chunk| chunk.seq);
464 let page = self.log_chunks(log_id, after, 500).await?;
465 let more = page.len() == 500;
466 chunks.extend(page);
467 if !more {
468 return Ok(chunks);
469 }
470 }
471 }
472
473 pub async fn logs(&self, a: LogsArgs) -> Result<Outcome<JobLog>> {
474 if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
475 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
476 }
477 let Some((_, log_id, done)) = self.job_for_log(&a.repo, &a.job).await? else {
478 return Ok(fail(FailureCode::NotFound, "No such job."));
479 };
480 let chunks = match a.tail {
481 // Its end: where a failed job says why.
482 Some(tail) => self.log_tail(&log_id, tail.clamp(1, g1t_contracts::actions::MAX_LOG_PAGE)).await?,
483 None => self.log_chunks(&log_id, a.after, g1t_contracts::actions::MAX_LOG_PAGE).await?,
484 };
485 Ok(Outcome::Ok(JobLog { chunks, done }))
486 }
487
488 /// The last `limit` chunks of a log, in order.
489 async fn log_tail(&self, log_id: &str, limit: u32) -> Result<Vec<LogChunk>> {
490 #[derive(Deserialize)]
491 struct Row {
492 seq: u64,
493 step: u32,
494 text: String,
495 }
496 let mut chunks: Vec<LogChunk> = self
497 .db
498 .prepare("SELECT seq, step, text FROM logs WHERE job_id = ? ORDER BY seq DESC LIMIT ?")
499 .bind(&[log_id.into(), limit.into()])?
500 .all()
501 .await?
502 .results::<Row>()?
503 .into_iter()
504 .map(|row| LogChunk { seq: row.seq, step: row.step, text: row.text })
505 .collect();
506 chunks.reverse();
507 Ok(chunks)
508 }
509
510 /// `job_log_text`: a job's whole log, to download.
511 pub async fn job_log_text(&self, a: JobLogTextArgs) -> Result<Outcome<JobLogText>> {
512 if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
513 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
514 }
515 let Some((job, log_id, done)) = self.job_for_log(&a.repo, &a.job).await? else {
516 return Ok(fail(FailureCode::NotFound, "No such job."));
517 };
518 let chunks = self.whole_log(&log_id).await?;
519 Ok(Outcome::Ok(JobLogText { job_id: job.id, name: job.name, steps: job.steps, chunks, done, omitted: false }))
520 }
521
522 /// `run_logs`: every job's whole log for one attempt, to download as one
523 /// archive.
524 pub async fn run_logs(&self, a: RunLogsArgs) -> Result<Outcome<Vec<JobLogText>>> {
525 if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
526 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
527 }
528 let run = check!(self.run_in(&a.repo, &a.id).await?);
529 let Some(AttemptJobs { jobs, log_ids }) = self.attempt_jobs(&run, a.attempt).await? else {
530 return Ok(fail(FailureCode::NotFound, format!("This run has no attempt {}.", a.attempt.unwrap_or_default())));
531 };
532 let mut out = Vec::with_capacity(jobs.len());
533 let mut total = 0usize;
534 for (job, log_id) in jobs.into_iter().zip(log_ids) {
535 let done = job.status == "completed";
536 let omitted = total >= MAX_RUN_LOG_BYTES;
537 let chunks = if omitted { Vec::new() } else { self.whole_log(&log_id).await? };
538 total += chunks.iter().map(|chunk| chunk.text.len()).sum::<usize>();
539 out.push(JobLogText { job_id: job.id, name: job.name, steps: job.steps, chunks, done, omitted });
540 }
541 Ok(Outcome::Ok(out))
542 }
543
544 /// `summaries`: what each job's steps wrote to `$GITHUB_STEP_SUMMARY`.
545 pub async fn summaries(&self, a: SummariesArgs) -> Result<Outcome<Vec<JobSummary>>> {
546 if self.visible_repo(&a.repo, &a.viewer).await?.is_none() {
547 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
548 }
549 let run = check!(self.run_in(&a.repo, &a.id).await?);
550 let Some(AttemptJobs { jobs, log_ids }) = self.attempt_jobs(&run, a.attempt).await? else {
551 return Ok(fail(FailureCode::NotFound, format!("This run has no attempt {}.", a.attempt.unwrap_or_default())));
552 };
553 #[derive(Deserialize)]
554 struct Row {
555 job_id: String,
556 step: u32,
557 markdown: String,
558 }
559 let rows = self
560 .db
561 .prepare("SELECT job_id, step, markdown FROM job_summaries WHERE job_id IN (SELECT value FROM json_each(?)) ORDER BY step")
562 .bind(&[serde_json::to_string(&log_ids)?.into()])?
563 .all()
564 .await?
565 .results::<Row>()?;
566 Ok(Outcome::Ok(
567 jobs.into_iter()
568 .zip(log_ids)
569 .filter_map(|(job, log_id)| {
570 let steps: Vec<StepSummary> = rows
571 .iter()
572 .filter(|row| row.job_id == log_id)
573 .map(|row| StepSummary { step: row.step, markdown: row.markdown.clone() })
574 .collect();
575 (!steps.is_empty()).then_some(JobSummary { job_id: job.id, name: job.name, steps })
576 })
577 .collect(),
578 ))
579 }
580
581 pub async fn set_workflow_enabled(&self, a: SetWorkflowEnabledArgs) -> Result<Outcome<Workflow>> {
582 let repo = match self.may(&a.actor, &a.repo, Capability::ManageSettings).await? {
583 Outcome::Ok(repo) => repo,
584 Outcome::Fail(refused) => return Ok(Outcome::Fail(refused)),
585 };
586 let wanted = a.workflow.trim_start_matches(".g1t/workflows/");
587 let row = self
588 .db
589 .prepare("SELECT * FROM workflows WHERE lower(repo) = lower(?) AND (id = ? OR path = ?)")
590 .bind(&[
591 format!("{}/{}", a.repo.namespace, a.repo.name).into(),
592 a.workflow.as_str().into(),
593 format!("{}/{wanted}", g1t_actions::workflow::FOLDER).into(),
594 ])?
595 .first::<WorkflowRow>(None)
596 .await?;
597 let Some(row) = row else {
598 return Ok(fail(FailureCode::NotFound, "No such workflow."));
599 };
600 // A workspace's required workflow (managed.rs) stays on.
601 if !a.enabled && self.required_here(&repo, &row.path).await? {
602 return Ok(fail(
603 FailureCode::Forbidden,
604 format!("{} is required by {}: only its owners can change where it runs, under its Settings, Actions.", row.name, repo.namespace),
605 ));
606 }
607 let state = if a.enabled { "active" } else { "disabled" };
608 self.db.prepare("UPDATE workflows SET state = ? WHERE id = ?").bind(&[state.into(), row.id.as_str().into()])?.run().await?;
609 let row = WorkflowRow { state: state.to_owned(), ..row };
610 Ok(Outcome::Ok(self.summary(&row).await?))
611 }
612
613 /// `check_runs`: workflow runs with their jobs, by commits, by a run's
614 /// id or by one of its jobs' ids, newest first, for the work service to
615 /// show as check suites and check runs. It decides who may see them.
616 pub async fn check_runs(&self, a: ActionsChecksArgs) -> Result<Vec<RunDetail>> {
617 let runs: Vec<RunRow> = if let Some(job) = &a.job_id {
618 self.db
619 .prepare("SELECT runs.* FROM runs JOIN jobs ON jobs.run_id = runs.id WHERE jobs.id = ? AND runs.repo_id = ?")
620 .bind(&[job.as_str().into(), a.repo_id.as_str().into()])?
621 .all()
622 .await?
623 .results::<RunRow>()?
624 } else if let Some(run) = &a.run_id {
625 self.db
626 .prepare("SELECT * FROM runs WHERE id = ? AND repo_id = ?")
627 .bind(&[run.as_str().into(), a.repo_id.as_str().into()])?
628 .all()
629 .await?
630 .results::<RunRow>()?
631 } else if a.shas.is_empty() {
632 Vec::new()
633 } else {
634 let shas: Vec<&String> = a.shas.iter().take(MAX_COMMITS).collect();
635 let marks = vec!["?"; shas.len()].join(", ");
636 let mut binds: Vec<worker::wasm_bindgen::JsValue> = vec![a.repo_id.as_str().into()];
637 binds.extend(shas.iter().map(|sha| sha.as_str().into()));
638 binds.push(CHECK_RUNS_LIMIT.into());
639 self.db
640 .prepare(format!("SELECT * FROM runs WHERE repo_id = ? AND sha IN ({marks}) ORDER BY id DESC LIMIT ?"))
641 .bind(&binds)?
642 .all()
643 .await?
644 .results::<RunRow>()?
645 };
646 if runs.is_empty() {
647 return Ok(Vec::new());
648 }
649 let marks = vec!["?"; runs.len()].join(", ");
650 let ids: Vec<worker::wasm_bindgen::JsValue> = runs.iter().map(|run| run.id.as_str().into()).collect();
651 let jobs = self
652 .db
653 .prepare(format!("SELECT * FROM jobs WHERE run_id IN ({marks}) ORDER BY rowid"))
654 .bind(&ids)?
655 .all()
656 .await?
657 .results::<JobRow>()?;
658 let mut by_run: std::collections::HashMap<String, Vec<Job>> = std::collections::HashMap::new();
659 for job in jobs {
660 by_run.entry(job.run_id.clone()).or_default().push(job_view(job));
661 }
662 Ok(runs
663 .iter()
664 .map(|run| RunDetail {
665 run: run.summary(),
666 jobs: by_run.remove(&run.id).unwrap_or_default(),
667 notes: Vec::new(),
668 approval: run.approval(),
669 pending_deployments: Vec::new(),
670 attempts: Vec::new(),
671 })
672 .collect())
673 }
674}
675
676/// The most runs `check_runs` reads for a set of commits.
677const CHECK_RUNS_LIMIT: u32 = 200;
678
679/// How the run's current attempt stands on its page: as its row says, and
680/// `waiting`, as on GitHub, while a job is held at an environment's rules.
681/// A job queued for a runner, or waiting for the jobs it needs, leaves a
682/// run that has started `in_progress`: it is still going.
683fn shown_status<'a>(status: &str, mut jobs: impl Iterator<Item = &'a str>) -> String {
684 if matches!(status, "queued" | "in_progress") && jobs.any(|job| job == "pending") {
685 "waiting".to_owned()
686 } else {
687 status.to_owned()
688 }
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694
695 fn shown(status: &str, jobs: &[&str]) -> String {
696 shown_status(status, jobs.iter().copied())
697 }
698
699 #[test]
700 fn a_job_queued_for_a_self_hosted_runner_keeps_its_run_going() {
701 // A re-run of failed jobs: the job that failed now succeeded, and the
702 // one that needs it waits for a runner with labels no runner has.
703 assert_eq!(shown("in_progress", &["completed", "queued"]), "in_progress");
704 assert_eq!(shown("queued", &["waiting", "waiting"]), "queued");
705 }
706
707 #[test]
708 fn a_job_held_at_an_environment_makes_its_run_wait() {
709 assert_eq!(shown("in_progress", &["completed", "pending"]), "waiting");
710 assert_eq!(shown("queued", &["pending"]), "waiting");
711 }
712
713 #[test]
714 fn a_finished_or_held_run_reads_as_its_row_says() {
715 assert_eq!(shown("completed", &["completed"]), "completed");
716 assert_eq!(shown("action_required", &["waiting"]), "action_required");
717 assert_eq!(shown("pending", &["pending"]), "pending");
718 }
719}