Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/core/src/ci/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
234 changes: 234 additions & 0 deletions backend/core/src/ci/reporter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
Ok(false)
}

/// Post a reply comment to a PR/MR. Used by `/ci run <wildcard>` 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 ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<Option<i64>> {
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────────
Expand All @@ -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";

Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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"})
);
}
}
74 changes: 74 additions & 0 deletions backend/core/src/ci/unpark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<MEvaluation>, 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.
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading