diff --git a/backend/core/src/ci/mod.rs b/backend/core/src/ci/mod.rs index ed4073ce..9ab8fb8a 100644 --- a/backend/core/src/ci/mod.rs +++ b/backend/core/src/ci/mod.rs @@ -30,5 +30,6 @@ pub use self::reporter::*; pub use self::reporting::*; pub use self::trigger::*; pub use self::unpark::{ - find_approval_gated_eval, unpark_approval, unpark_no_cache_for_org, unpark_no_workers_for_org, + find_approval_gated_eval, unpark_approval, unpark_approval_with_wildcard, + unpark_no_cache_for_org, unpark_no_workers_for_org, }; diff --git a/backend/core/src/ci/reporter.rs b/backend/core/src/ci/reporter.rs index 4a423dac..f400570c 100644 --- a/backend/core/src/ci/reporter.rs +++ b/backend/core/src/ci/reporter.rs @@ -119,6 +119,21 @@ pub trait CiReporter: Send + Sync + std::fmt::Debug + 'static { async fn is_repo_writer(&self, _owner: &str, _repo: &str, _username: &str) -> Result { Ok(false) } + + /// Post a reply comment to a PR/MR. Used by `/ci run ` to + /// surface wildcard parse errors back to the commenter. + /// + /// Default impl is a no-op so reporters that do not implement + /// outbound comments simply swallow the request. + async fn post_pr_comment( + &self, + _owner: &str, + _repo: &str, + _pr_number: u64, + _body: &str, + ) -> Result<()> { + Ok(()) + } } // ── NoopCiReporter ──────────────────────────────────────────────────────────── @@ -147,6 +162,18 @@ pub struct GiteaReporter { client: reqwest::Client, } +fn gitea_comment_url(base_url: &str, owner: &str, repo: &str, pr_number: u64) -> String { + format!( + "{}/api/v1/repos/{}/{}/issues/{}/comments", + base_url, owner, repo, pr_number + ) +} + +#[derive(Debug, Serialize)] +struct ForgeCommentPayload<'a> { + body: &'a str, +} + impl GiteaReporter { pub fn new( client: reqwest::Client, @@ -270,6 +297,40 @@ impl CiReporter for GiteaReporter { "admin" | "owner" | "write" )) } + + async fn post_pr_comment( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + ) -> Result<()> { + let url = gitea_comment_url(&self.base_url, owner, repo, pr_number); + let payload = ForgeCommentPayload { body }; + + let resp = self + .client + .post(&url) + .header("Authorization", format!("token {}", self.token)) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .context("Failed to send Gitea comment request")?; + + let status = resp.status(); + if !status.is_success() { + let resp_body = resp.text().await.unwrap_or_default(); + warn!( + gitea_url = %url, + http_status = %status, + body = %resp_body, + "Gitea PR comment post failed" + ); + anyhow::bail!("Gitea returned {}: {}", status, resp_body); + } + Ok(()) + } } // ── GitlabReporter ──────────────────────────────────────────────────────────── @@ -349,6 +410,14 @@ fn gitlab_project_id(owner: &str, repo: &str) -> String { format!("{}/{}", owner, repo).replace('/', "%2F") } +fn gitlab_comment_url(base_url: &str, owner: &str, repo: &str, pr_number: u64) -> String { + let project_id = gitlab_project_id(owner, repo); + format!( + "{}/api/v4/projects/{}/merge_requests/{}/notes", + base_url, project_id, pr_number + ) +} + #[async_trait] impl CiReporter for GitlabReporter { async fn report(&self, report: &CiReport) -> Result> { @@ -423,6 +492,40 @@ impl CiReporter for GitlabReporter { .iter() .any(|m| m.username.eq_ignore_ascii_case(username) && m.access_level >= 30)) } + + async fn post_pr_comment( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + ) -> Result<()> { + let url = gitlab_comment_url(&self.base_url, owner, repo, pr_number); + let payload = ForgeCommentPayload { body }; + + let resp = self + .client + .post(&url) + .header("PRIVATE-TOKEN", &self.token) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + .context("Failed to send GitLab comment request")?; + + let status = resp.status(); + if !status.is_success() { + let resp_body = resp.text().await.unwrap_or_default(); + warn!( + gitlab_url = %url, + http_status = %status, + body = %resp_body, + "GitLab MR comment post failed" + ); + anyhow::bail!("GitLab returned {}: {}", status, resp_body); + } + Ok(()) + } } // ── GithubReporter ──────────────────────────────────────────────────────────── @@ -444,6 +547,13 @@ pub struct GithubReporter { client: reqwest::Client, } +fn github_comment_url(base_url: &str, owner: &str, repo: &str, pr_number: u64) -> String { + format!( + "{}/repos/{}/{}/issues/{}/comments", + base_url, owner, repo, pr_number + ) +} + impl GithubReporter { const DEFAULT_API_URL: &'static str = "https://api.github.com"; @@ -571,6 +681,41 @@ impl CiReporter for GithubReporter { .context("Failed to parse GitHub permission response")?; Ok(matches!(parsed.permission.as_str(), "admin" | "write")) } + + async fn post_pr_comment( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + ) -> Result<()> { + let url = github_comment_url(&self.base_url, owner, repo, pr_number); + let payload = ForgeCommentPayload { body }; + + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.token)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .json(&payload) + .send() + .await + .context("Failed to send GitHub comment request")?; + + let status = resp.status(); + if !status.is_success() { + let resp_body = resp.text().await.unwrap_or_default(); + warn!( + github_url = %url, + http_status = %status, + body = %resp_body, + "GitHub PR comment post failed" + ); + anyhow::bail!("GitHub returned {}: {}", status, resp_body); + } + Ok(()) + } } #[derive(Debug, Deserialize)] @@ -843,6 +988,51 @@ impl CiReporter for GithubAppReporter { .context("Failed to parse GitHub permission response")?; Ok(matches!(parsed.permission.as_str(), "admin" | "write")) } + + async fn post_pr_comment( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + ) -> Result<()> { + let token = crate::ci::github_app::get_installation_token( + &self.client, + self.app_id, + &self.private_key_pem, + self.installation_id, + ) + .await + .context("Failed to mint GitHub App installation token")?; + + let url = github_comment_url(&self.api_base_url, owner, repo, pr_number); + let payload = ForgeCommentPayload { body }; + + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .json(&payload) + .send() + .await + .context("Failed to send GitHub App comment request")?; + + let status = resp.status(); + if !status.is_success() { + let resp_body = resp.text().await.unwrap_or_default(); + warn!( + github_url = %url, + http_status = %status, + body = %resp_body, + installation_id = self.installation_id, + "GitHub App PR comment post failed" + ); + anyhow::bail!("GitHub App returned {}: {}", status, resp_body); + } + Ok(()) + } } // ── factory ────────────────────────────────────────────────────────────────── @@ -1308,4 +1498,48 @@ mod tests { let got = parse_owner_repo("git@gitea.example.com:group/sub/repo.git"); assert_eq!(got, Some(("group".into(), "sub/repo".into()))); } + + #[test] + fn gitlab_comment_url_url_encodes_owner_repo() { + let url = gitlab_comment_url( + "https://gitlab.example.com", + "group/subgroup", + "demo", + 7, + ); + assert_eq!( + url, + "https://gitlab.example.com/api/v4/projects/group%2Fsubgroup%2Fdemo/merge_requests/7/notes" + ); + } + + #[test] + fn github_comment_url_targets_issues_endpoint() { + let url = github_comment_url("https://api.github.com", "octo", "demo", 42); + assert_eq!( + url, + "https://api.github.com/repos/octo/demo/issues/42/comments" + ); + } + + #[test] + fn gitea_comment_url_targets_issues_endpoint() { + let url = gitea_comment_url("https://gitea.example.com", "octo", "demo", 42); + assert_eq!( + url, + "https://gitea.example.com/api/v1/repos/octo/demo/issues/42/comments" + ); + } + + #[test] + fn forge_comment_payload_serializes_with_body_field() { + let payload = ForgeCommentPayload { + body: "Could not parse wildcard `bad`: error", + }; + let json = serde_json::to_value(&payload).unwrap(); + assert_eq!( + json, + serde_json::json!({"body": "Could not parse wildcard `bad`: error"}) + ); + } } diff --git a/backend/core/src/ci/unpark.rs b/backend/core/src/ci/unpark.rs index 67cb2a49..e343170b 100644 --- a/backend/core/src/ci/unpark.rs +++ b/backend/core/src/ci/unpark.rs @@ -141,6 +141,38 @@ pub async fn unpark_approval( Ok(Some(ae.update(db).await?)) } +/// Transition a single evaluation parked in `Waiting + Approval` back to +/// `Queued` while overriding its `wildcard` column. Same guards as +/// [`unpark_approval`]; on success, the same row update writes both the +/// status flip and the new wildcard so the dispatcher reads a consistent +/// row when it next polls. +pub async fn unpark_approval_with_wildcard( + db: &impl ConnectionTrait, + evaluation_id: EvaluationId, + wildcard: &str, +) -> Result, sea_orm::DbErr> { + let Some(eval) = EEvaluation::find_by_id(evaluation_id).one(db).await? else { + return Ok(None); + }; + if eval.status != EvaluationStatus::Waiting { + return Ok(None); + } + let is_approval = eval + .waiting_reason + .as_ref() + .and_then(WaitingReason::from_json) + .is_some_and(|r| matches!(r, WaitingReason::Approval { .. })); + if !is_approval { + return Ok(None); + } + let mut ae: AEvaluation = eval.into(); + ae.status = Set(EvaluationStatus::Queued); + ae.waiting_reason = Set(None); + ae.wildcard = Set(wildcard.to_string()); + ae.updated_at = Set(crate::types::now()); + Ok(Some(ae.update(db).await?)) +} + /// Find the evaluation that is parked in `Waiting + Approval` for the given /// project + PR number combination. Used by the comment-based unpark path /// where the webhook only carries the PR number, not the eval id. @@ -220,6 +252,48 @@ mod tests { assert!(unpark_approval(&db, parked.id).await.unwrap().is_none()); } + #[tokio::test] + async fn unpark_approval_with_wildcard_overrides_wildcard_and_requeues() { + let mut parked = waiting_eval(WaitingReason::approval(7, "octocat")); + parked.wildcard = "*".into(); + + let mut requeued = parked.clone(); + requeued.status = EvaluationStatus::Queued; + requeued.waiting_reason = None; + requeued.wildcard = "packages.x86_64-linux.foo".into(); + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![parked.clone()]]) + .append_query_results([vec![requeued.clone()]]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + .into_connection(); + + let out = unpark_approval_with_wildcard(&db, parked.id, "packages.x86_64-linux.foo") + .await + .unwrap() + .unwrap(); + assert_eq!(out.status, EvaluationStatus::Queued); + assert!(out.waiting_reason.is_none()); + assert_eq!(out.wildcard, "packages.x86_64-linux.foo"); + } + + #[tokio::test] + async fn unpark_approval_with_wildcard_no_op_for_non_approval_reason() { + let parked = waiting_eval(WaitingReason::NoCache); + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![parked.clone()]]) + .into_connection(); + assert!( + unpark_approval_with_wildcard(&db, parked.id, "packages.*.*") + .await + .unwrap() + .is_none() + ); + } + fn make_project(org: OrganizationId) -> entity::project::Model { entity::project::Model { id: ProjectId::now_v7(), diff --git a/backend/test-support/src/fakes/ci_reporter.rs b/backend/test-support/src/fakes/ci_reporter.rs index b644bce8..c3d01e96 100644 --- a/backend/test-support/src/fakes/ci_reporter.rs +++ b/backend/test-support/src/fakes/ci_reporter.rs @@ -9,10 +9,20 @@ use async_trait::async_trait; use gradient_core::ci::{CiReport, CiReporter}; use std::sync::Mutex; +/// A PR/MR comment captured by [`RecordingCiReporter`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordedComment { + pub owner: String, + pub repo: String, + pub pr_number: u64, + pub body: String, +} + /// In-memory `CiReporter` for tests. Records every call in order. #[derive(Debug, Default)] pub struct RecordingCiReporter { pub calls: Mutex>, + pub comments: Mutex>, } impl RecordingCiReporter { @@ -20,10 +30,15 @@ impl RecordingCiReporter { Self::default() } - /// Returns a snapshot of all recorded reports in call order. + /// Returns a snapshot of all recorded status reports in call order. pub fn calls(&self) -> Vec { self.calls.lock().unwrap().clone() } + + /// Returns a snapshot of all recorded PR/MR comments in call order. + pub fn comments(&self) -> Vec { + self.comments.lock().unwrap().clone() + } } #[async_trait] @@ -32,4 +47,20 @@ impl CiReporter for RecordingCiReporter { self.calls.lock().unwrap().push(report.clone()); Ok(None) } + + async fn post_pr_comment( + &self, + owner: &str, + repo: &str, + pr_number: u64, + body: &str, + ) -> Result<()> { + self.comments.lock().unwrap().push(RecordedComment { + owner: owner.to_string(), + repo: repo.to_string(), + pr_number, + body: body.to_string(), + }); + Ok(()) + } } diff --git a/backend/web/src/endpoints/forge_hooks/trigger.rs b/backend/web/src/endpoints/forge_hooks/trigger.rs index 603aafa1..87f89122 100644 --- a/backend/web/src/endpoints/forge_hooks/trigger.rs +++ b/backend/web/src/endpoints/forge_hooks/trigger.rs @@ -10,9 +10,10 @@ use super::response::{QueuedEvaluation, SkippedProject, WebhookTriggerOutcome}; use entity::project_trigger as ept; use gradient_core::ci::{ APPROVAL_ACTION_ID, ApplyInput, ApplyOutcome, ApprovalInfo, ForgeType, apply_trigger, - find_approval_gated_eval, unpark_approval, + find_approval_gated_eval, unpark_approval, unpark_approval_with_wildcard, }; use gradient_core::types::triggers::{TriggerConfig, TriggerType}; +use gradient_core::types::wildcard::Wildcard; use gradient_core::types::*; use scheduler::Scheduler; use sea_orm::ActiveValue::Set; @@ -998,9 +999,10 @@ pub(super) async fn handle_issue_comment( } }; - if !is_ci_run_command(&comment_body) { - return; - } + let cmd = match parse_ci_run_command(&comment_body) { + Some(cmd) => cmd, + None => return, + }; let Some(pr_number) = pr_number else { warn!("comment webhook: /ci run but no PR number"); return; @@ -1042,9 +1044,30 @@ pub(super) async fn handle_issue_comment( } }; + let wildcard_override: Option = match &cmd { + CiRunCommand::Plain => None, + CiRunCommand::WithWildcard(raw) => match raw.parse::() { + Ok(_) => Some(raw.clone()), + Err(e) => { + warn!(wildcard = %raw, error = %e, "/ci run wildcard rejected"); + post_wildcard_error_comment( + state, + &integration_ids, + owner, + repo, + pr_number, + raw, + &e, + ) + .await; + return; + } + }, + }; + let mut unparked_any = false; - for integration_id in integration_ids { - let project_ids = match active_project_ids_for_integration(state, integration_id).await { + for integration_id in &integration_ids { + let project_ids = match active_project_ids_for_integration(state, *integration_id).await { Ok(rows) => rows, Err(e) => { warn!(error = %e, "comment webhook: failed to load project list"); @@ -1066,12 +1089,17 @@ pub(super) async fn handle_issue_comment( ); continue; } - match unpark_approval(&state.web_db, eval.id).await { + let unpark_result = match &wildcard_override { + None => unpark_approval(&state.web_db, eval.id).await, + Some(w) => unpark_approval_with_wildcard(&state.web_db, eval.id, w).await, + }; + match unpark_result { Ok(Some(unparked)) => { info!( evaluation_id = %eval.id, pr_number, %sender, + wildcard_override = wildcard_override.as_deref(), "PR approval gate cleared via /ci run comment" ); dispatch_approval_granted(state, &unparked).await; @@ -1089,6 +1117,54 @@ pub(super) async fn handle_issue_comment( } } +/// Posts a reply comment to the PR explaining a `/ci run ` +/// parse failure. Walks every project owned by the matching +/// integrations and uses the first project with a usable +/// `ForgeStatusReport` action's reporter. Failures are logged and +/// swallowed - a comment-post failure must not crash the webhook +/// handler. +async fn post_wildcard_error_comment( + state: &Arc, + integration_ids: &[IntegrationId], + owner: &str, + repo: &str, + pr_number: u64, + raw_wildcard: &str, + parse_error: &gradient_core::types::input::InputError, +) { + let body = format!( + "Could not parse wildcard `{}`: {}", + raw_wildcard, parse_error + ); + + for integration_id in integration_ids { + let project_ids = match active_project_ids_for_integration(state, *integration_id).await { + Ok(rows) => rows, + Err(e) => { + warn!(error = %e, %integration_id, "/ci run wildcard: failed to load projects for reply comment"); + continue; + } + }; + for project_id in project_ids { + let reporter = + match gradient_core::ci::actions::reporter_for_project(state, project_id).await { + Ok(Some(r)) => r, + Ok(None) => continue, + Err(e) => { + warn!(error = %e, %project_id, "/ci run wildcard: resolving reporter for reply comment"); + continue; + } + }; + match reporter.post_pr_comment(owner, repo, pr_number, &body).await { + Ok(()) => return, + Err(e) => { + warn!(error = %e, %project_id, "/ci run wildcard: reply comment post failed, trying next project"); + } + } + } + } +} + fn debug_no_match(pr_number: u64) { tracing::debug!( pr_number, @@ -1096,28 +1172,57 @@ fn debug_no_match(pr_number: u64) { ); } -/// Lifts `/ci run` from a comment body. The command must appear on its own -/// line (after trimming whitespace). Blank lines and forge quote-reply lines -/// (`> …`) are skipped so a maintainer can quote the PR context above the -/// command, but any other prose before or after the command disqualifies -/// the comment - that protects against accidental unparks when a contributor -/// quotes an earlier `/ci run` in a reply. -fn is_ci_run_command(body: &str) -> bool { - let mut saw_command = false; +/// Outcome of parsing a `/ci run [wildcard]` comment. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum CiRunCommand { + /// Bare `/ci run` — unpark with the wildcard already on the eval row. + Plain, + /// `/ci run ` — unpark and override the eval row's wildcard + /// with this raw string. Not yet validated; the caller must pass it + /// through `Wildcard::from_str` and reject (with a reply comment) on + /// parse failure. + WithWildcard(String), +} + +/// Lifts `/ci run` (with or without a wildcard argument) from a comment +/// body. The command must appear on its own line (after trimming +/// whitespace). Blank lines and forge quote-reply lines (`> …`) are +/// skipped so a maintainer can quote the PR context above the command; +/// any other prose before or after the command disqualifies the comment. +pub(super) fn parse_ci_run_command(body: &str) -> Option { + const PREFIX: &str = "/ci run"; + + let mut found: Option = None; for line in body.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('>') { continue; } - if saw_command { - return false; + if found.is_some() { + return None; } - if !trimmed.eq_ignore_ascii_case("/ci run") { - return false; + if trimmed.len() < PREFIX.len() { + return None; } - saw_command = true; + let (prefix, rest) = trimmed.split_at(PREFIX.len()); + if !prefix.eq_ignore_ascii_case(PREFIX) { + return None; + } + if rest.is_empty() { + found = Some(CiRunCommand::Plain); + continue; + } + if !rest.starts_with(|c: char| c.is_ascii_whitespace()) { + return None; + } + let arg = rest.trim(); + if arg.is_empty() { + found = Some(CiRunCommand::Plain); + continue; + } + found = Some(CiRunCommand::WithWildcard(arg.to_string())); } - saw_command + found } fn github_installation_id_from_comment_body(body: &[u8]) -> Option { @@ -1165,7 +1270,9 @@ async fn active_project_ids_for_integration( #[cfg(test)] mod tests { use super::super::WebhookTriggerOutcome; - use super::{glob_match_pattern, glob_matches, is_ci_run_command, normalize_repo_url}; + use super::{ + CiRunCommand, glob_match_pattern, glob_matches, normalize_repo_url, parse_ci_run_command, + }; #[test] fn normalize_strips_dot_git_suffix() { @@ -1213,36 +1320,76 @@ mod tests { } #[test] - fn ci_run_command_matches_canonical_form() { - assert!(is_ci_run_command("/ci run")); + fn parse_ci_run_command_plain_returns_plain() { + assert!(matches!( + parse_ci_run_command("/ci run"), + Some(CiRunCommand::Plain) + )); + assert!(matches!( + parse_ci_run_command(" /ci run "), + Some(CiRunCommand::Plain) + )); + assert!(matches!( + parse_ci_run_command("\n/ci run\n"), + Some(CiRunCommand::Plain) + )); } #[test] - fn ci_run_command_matches_with_surrounding_whitespace() { - assert!(is_ci_run_command(" /ci run ")); - assert!(is_ci_run_command("\n/ci run\n")); + fn parse_ci_run_command_case_insensitive_prefix() { + assert!(matches!( + parse_ci_run_command("/CI Run"), + Some(CiRunCommand::Plain) + )); + assert!(matches!( + parse_ci_run_command("/Ci RUN packages.*.*"), + Some(CiRunCommand::WithWildcard(ref w)) if w == "packages.*.*" + )); } #[test] - fn ci_run_command_matches_after_quote_reply() { - assert!(is_ci_run_command( - "> @maintainer asked us to retrigger\n> after rebasing main\n\n/ci run" + fn parse_ci_run_command_with_wildcard() { + assert!(matches!( + parse_ci_run_command("/ci run packages.*.*"), + Some(CiRunCommand::WithWildcard(ref w)) if w == "packages.*.*" )); } #[test] - fn ci_run_command_case_insensitive() { - assert!(is_ci_run_command("/CI Run")); - assert!(is_ci_run_command("/Ci RUN")); + fn parse_ci_run_command_with_complex_wildcard() { + let body = "/ci run packages.*.foo,!packages.x86_64-linux.broken"; + let Some(CiRunCommand::WithWildcard(w)) = parse_ci_run_command(body) else { + panic!("expected WithWildcard"); + }; + assert_eq!(w, "packages.*.foo,!packages.x86_64-linux.broken"); + } + + #[test] + fn parse_ci_run_command_trims_trailing_whitespace_around_wildcard() { + let body = " /ci run packages.*.* "; + let Some(CiRunCommand::WithWildcard(w)) = parse_ci_run_command(body) else { + panic!("expected WithWildcard"); + }; + assert_eq!(w, "packages.*.*"); + } + + #[test] + fn parse_ci_run_command_after_quote_reply() { + let body = + "> @maintainer asked us to retrigger\n> after rebasing main\n\n/ci run packages.*.*"; + let Some(CiRunCommand::WithWildcard(w)) = parse_ci_run_command(body) else { + panic!("expected WithWildcard"); + }; + assert_eq!(w, "packages.*.*"); } #[test] - fn ci_run_command_rejects_unrelated_text() { - assert!(!is_ci_run_command("looks good")); - assert!(!is_ci_run_command("/ci run please")); - assert!(!is_ci_run_command("foo\n/ci run\nbar")); + fn parse_ci_run_command_rejects_unrelated() { + assert!(parse_ci_run_command("looks good").is_none()); + assert!(parse_ci_run_command("/ci runfoo").is_none()); + assert!(parse_ci_run_command("foo\n/ci run\nbar").is_none()); assert!( - !is_ci_run_command("quote-reply context\n\n/ci run"), + parse_ci_run_command("quote-reply context\n\n/ci run").is_none(), "non-quote prose before /ci run must reject" ); } diff --git a/docs/gradient-api.yaml b/docs/gradient-api.yaml index ca6bf840..79b30c60 100644 --- a/docs/gradient-api.yaml +++ b/docs/gradient-api.yaml @@ -6449,7 +6449,9 @@ components: repo writer, and the trigger has `require_approval = true`. The evaluation stays parked until a maintainer clicks "Approve and run" on the GitHub Check or comments `/ci run` on - Gitea/Forgejo/GitLab. + Gitea/Forgejo/GitLab. The command optionally accepts a wildcard + (`/ci run packages.x86_64-linux.foo`) that overrides the + project's wildcard for this one run only. - `no_cache`: the project's organisation has no writable cache subscription, so build outputs would have nowhere to land. The evaluation auto-unparks the moment a writable cache is @@ -7457,7 +7459,10 @@ components: When `true` (default), PRs from contributors who are not forge repo writers are parked in `Waiting + WaitingReason::Approval` until a maintainer clicks "Approve and run" on the GitHub Check - or comments `/ci run` on Gitea/Forgejo/GitLab. Set to `false` to + or comments `/ci run` on Gitea/Forgejo/GitLab. The comment + command optionally accepts a wildcard + (`/ci run packages.x86_64-linux.foo`) that overrides the + project's wildcard for this one run only. Set to `false` to disable the gate and run every PR build automatically. TimeTriggerConfig: diff --git a/docs/src/tests.md b/docs/src/tests.md index 9c996a84..c98821c4 100644 --- a/docs/src/tests.md +++ b/docs/src/tests.md @@ -2482,10 +2482,23 @@ immediately. Coverage: - `web/src/endpoints/forge_hooks/events.rs` - extraction of `pr_number`, `pr_author`, `is_fork`, `base_owner`, `base_repo` from GitHub / Gitea / GitLab payloads. -- `web/src/endpoints/forge_hooks/trigger.rs::is_ci_run_command_*` - - recogniser for the `/ci run` comment unpark command (case - insensitive, allows leading quote-reply lines, rejects trailing - noise). +- `web/src/endpoints/forge_hooks/trigger.rs::parse_ci_run_command_*` - + recogniser for the `/ci run [wildcard]` comment unpark command + (case insensitive, allows leading quote-reply lines, rejects + multi-line prose, captures an optional trailing wildcard string + for one-shot overrides; issue #274). +- `core/src/ci/unpark.rs::unpark_approval_with_wildcard_*` + (issue #274) - the new helper writes the maintainer-supplied + wildcard into the same row update that flips `Waiting -> Queued`, + so the dispatcher reads a consistent row; same guards as + `unpark_approval`. +- `core/src/ci/reporter.rs::{gitea,github,gitlab}_comment_url_*` + (issue #274) - per-forge URL builders for the `post_pr_comment` + trait method that surfaces wildcard parse errors back to the + commenter. +- `core/src/ci/reporter.rs::forge_comment_payload_serializes_with_body_field` + (issue #274) - the shared `{"body": "..."}` JSON payload sent to + all three forges. - `core/src/ci/unpark.rs::unpark_approval_*` - transitions `Waiting + Approval` back to `Queued` once a maintainer authorises the PR; no-ops when the row's reason is something else (NoCache /