feat: Complete native Actions delivery #53
@@ -1,451 +1,486 @@
|
||||
use serde::Deserialize;
|
||||
use syncode_control_runs::Origin;
|
||||
use syncode_workflow::{Event, EventKind, GitReference};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::sources::ChangedFilesRequest;
|
||||
|
||||
/// One thing the repository event source says happened, in the shape deciding needs: which
|
||||
/// repository, at which commit, and what the event was.
|
||||
///
|
||||
/// The event source speaks about branches and tags as full references. Everything
|
||||
/// past this boundary keeps the reference kind while using its short name, because
|
||||
/// that is what a workflow writes in its filters.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Delivery {
|
||||
repository: String,
|
||||
content_repository: Option<String>,
|
||||
commit: String,
|
||||
event: Event,
|
||||
/// Which pull request this came from. It names the reference the commit
|
||||
/// lives on, which no branch does for a pull request.
|
||||
pull_request: Option<u64>,
|
||||
changed_files: Option<ChangedFilesRequest>,
|
||||
workflow: Option<String>,
|
||||
delivery: Option<String>,
|
||||
principal: Option<String>,
|
||||
secrets_allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DeliveryError {
|
||||
#[error("the repository event source sent a body that is not a known event: {0}")]
|
||||
Unreadable(#[from] serde_json::Error),
|
||||
|
||||
#[error("the repository event source sent event {0:?}, which does not trigger runs")]
|
||||
Unsupported(String),
|
||||
|
||||
#[error("the event names an unsupported repository reference {0:?}")]
|
||||
NotARepositoryReference(String),
|
||||
|
||||
#[error("the event carries no commit to compile from")]
|
||||
NoCommit,
|
||||
|
||||
#[error("the requested event carries no workflow to compile")]
|
||||
NoWorkflow,
|
||||
|
||||
#[error(
|
||||
"the repository event source sent a pull request {0:?} this control plane does not act on"
|
||||
)]
|
||||
UnactedPullRequest(String),
|
||||
|
||||
#[error("the repository event uses unsupported protocol version {0:?}")]
|
||||
UnsupportedProtocol(String),
|
||||
|
||||
#[error("the repository event uses unsupported schema version {0}")]
|
||||
UnsupportedSchema(u32),
|
||||
|
||||
#[error("the repository event body names message type {0:?}")]
|
||||
UnexpectedMessageType(String),
|
||||
|
||||
#[error("the repository event carries invalid {0}")]
|
||||
InvalidNativeField(&'static str),
|
||||
}
|
||||
|
||||
/// The push payload, narrowed to the fields a decision needs. Everything else
|
||||
/// the event source sends is ignored on purpose: fields we do not read cannot
|
||||
/// break us when the source adds or renames them.
|
||||
#[derive(Deserialize)]
|
||||
struct Push {
|
||||
#[serde(rename = "ref")]
|
||||
reference: String,
|
||||
after: String,
|
||||
#[serde(default)]
|
||||
head_commit: Option<HeadCommit>,
|
||||
repository: Repository,
|
||||
#[serde(default)]
|
||||
commits: Vec<Commit>,
|
||||
#[serde(default)]
|
||||
sender: Option<Actor>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HeadCommit {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Repository {
|
||||
full_name: String,
|
||||
}
|
||||
|
||||
/// The pull request payload, narrowed the same way the push one is.
|
||||
#[derive(Deserialize)]
|
||||
struct PullRequestEvent {
|
||||
action: String,
|
||||
number: u64,
|
||||
pull_request: PullRequestBody,
|
||||
repository: Repository,
|
||||
#[serde(default)]
|
||||
sender: Option<Actor>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Actor {
|
||||
login: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PullRequestBody {
|
||||
head: Reference,
|
||||
base: Reference,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reference {
|
||||
#[serde(default)]
|
||||
sha: String,
|
||||
#[serde(rename = "ref", default)]
|
||||
reference: String,
|
||||
#[serde(default, rename = "repo")]
|
||||
repository: Option<Repository>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Commit {
|
||||
#[serde(default)]
|
||||
added: Vec<String>,
|
||||
#[serde(default)]
|
||||
removed: Vec<String>,
|
||||
#[serde(default)]
|
||||
modified: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RequestedEvent {
|
||||
#[serde(rename = "ref")]
|
||||
reference: String,
|
||||
after: String,
|
||||
workflow: String,
|
||||
repository: Repository,
|
||||
#[serde(default)]
|
||||
sender: Option<Actor>,
|
||||
}
|
||||
|
||||
/// A commit that is all zeroes is how the event source says a reference was deleted.
|
||||
/// There is nothing to read a workflow from, so there is nothing to run.
|
||||
const DELETED: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
const BRANCH_PREFIX: &str = "refs/heads/";
|
||||
const TAG_PREFIX: &str = "refs/tags/";
|
||||
|
||||
fn full_reference(reference: &str) -> Result<GitReference, DeliveryError> {
|
||||
if let Some(branch) = reference.strip_prefix(BRANCH_PREFIX) {
|
||||
return branch_or_error(branch, reference);
|
||||
}
|
||||
if let Some(tag) = reference.strip_prefix(TAG_PREFIX) {
|
||||
return tag_or_error(tag, reference);
|
||||
}
|
||||
Err(DeliveryError::NotARepositoryReference(reference.to_owned()))
|
||||
}
|
||||
|
||||
fn requested_reference(reference: &str) -> Result<GitReference, DeliveryError> {
|
||||
if !reference.starts_with("refs/") {
|
||||
return branch_or_error(reference, reference);
|
||||
}
|
||||
full_reference(reference)
|
||||
}
|
||||
|
||||
fn branch_or_error(name: &str, original: &str) -> Result<GitReference, DeliveryError> {
|
||||
if name.is_empty() {
|
||||
return Err(DeliveryError::NotARepositoryReference(original.to_owned()));
|
||||
}
|
||||
Ok(GitReference::Branch(name.to_owned()))
|
||||
}
|
||||
|
||||
fn tag_or_error(name: &str, original: &str) -> Result<GitReference, DeliveryError> {
|
||||
if name.is_empty() {
|
||||
return Err(DeliveryError::NotARepositoryReference(original.to_owned()));
|
||||
}
|
||||
Ok(GitReference::Tag(name.to_owned()))
|
||||
}
|
||||
|
||||
impl Delivery {
|
||||
pub fn read_all(event: &str, body: &[u8]) -> Result<Vec<Self>, DeliveryError> {
|
||||
match event {
|
||||
"repository.ref.updated" => crate::native_events::read(body).map(|updates| {
|
||||
updates
|
||||
.into_iter()
|
||||
.map(|update| {
|
||||
let changed_files = match &update.reference {
|
||||
GitReference::Branch(_) => Some(ChangedFilesRequest::Commits {
|
||||
before: update.before,
|
||||
after: update.after.clone(),
|
||||
}),
|
||||
GitReference::Tag(_) => None,
|
||||
};
|
||||
Self {
|
||||
repository: update.repository,
|
||||
content_repository: None,
|
||||
commit: update.after,
|
||||
event: Event::new(EventKind::Push, update.reference, Vec::new()),
|
||||
pull_request: None,
|
||||
changed_files,
|
||||
workflow: None,
|
||||
delivery: None,
|
||||
principal: update.principal,
|
||||
secrets_allowed: true,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
"push" => Ok(Vec::new()),
|
||||
_ => Self::read(event, body).map(|delivery| vec![delivery]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read what the repository event source delivered. The event name comes
|
||||
/// from the header rather than the body, because that is where the source states
|
||||
/// it; a body alone does not say which kind of event it describes.
|
||||
pub fn read(event: &str, body: &[u8]) -> Result<Self, DeliveryError> {
|
||||
match event {
|
||||
"push" => Self::push(body),
|
||||
"pull_request" => Self::pull_request(body),
|
||||
"workflow_dispatch" => Self::requested(body, EventKind::Manual),
|
||||
"schedule" => Self::requested(body, EventKind::Schedule),
|
||||
other => Err(DeliveryError::Unsupported(other.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
fn requested(body: &[u8], kind: EventKind) -> Result<Self, DeliveryError> {
|
||||
let event: RequestedEvent = serde_json::from_slice(body)?;
|
||||
if event.after == DELETED || event.after.is_empty() {
|
||||
return Err(DeliveryError::NoCommit);
|
||||
}
|
||||
if event.workflow.is_empty() {
|
||||
return Err(DeliveryError::NoWorkflow);
|
||||
}
|
||||
let reference = requested_reference(&event.reference)?;
|
||||
Ok(Self {
|
||||
repository: event.repository.full_name,
|
||||
content_repository: None,
|
||||
commit: event.after,
|
||||
event: Event::new(kind, reference, Vec::new()),
|
||||
pull_request: None,
|
||||
changed_files: None,
|
||||
workflow: Some(event.workflow),
|
||||
delivery: None,
|
||||
principal: event.sender.map(|actor| actor.login),
|
||||
secrets_allowed: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn push(body: &[u8]) -> Result<Self, DeliveryError> {
|
||||
let push: Push = serde_json::from_slice(body)?;
|
||||
if push.after == DELETED || push.after.is_empty() {
|
||||
return Err(DeliveryError::NoCommit);
|
||||
}
|
||||
let reference = full_reference(&push.reference)?;
|
||||
let commit = match &reference {
|
||||
GitReference::Branch(_) => push.after,
|
||||
GitReference::Tag(_) => push
|
||||
.head_commit
|
||||
.map(|head| head.id)
|
||||
.filter(|commit| !commit.is_empty() && commit != DELETED)
|
||||
.ok_or(DeliveryError::NoCommit)?,
|
||||
};
|
||||
|
||||
let mut changed: Vec<String> = push
|
||||
.commits
|
||||
.into_iter()
|
||||
.flat_map(|commit| {
|
||||
commit
|
||||
.added
|
||||
.into_iter()
|
||||
.chain(commit.removed)
|
||||
.chain(commit.modified)
|
||||
})
|
||||
.collect();
|
||||
changed.sort_unstable();
|
||||
changed.dedup();
|
||||
|
||||
Ok(Self {
|
||||
repository: push.repository.full_name,
|
||||
content_repository: None,
|
||||
commit,
|
||||
event: Event::new(EventKind::Push, reference, changed),
|
||||
pull_request: None,
|
||||
changed_files: None,
|
||||
workflow: None,
|
||||
delivery: None,
|
||||
principal: push.sender.map(|actor| actor.login),
|
||||
secrets_allowed: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn pull_request(body: &[u8]) -> Result<Self, DeliveryError> {
|
||||
let event: PullRequestEvent = serde_json::from_slice(body)?;
|
||||
// Closing a pull request does not start work, and the dialect models
|
||||
// reopening and synchronising as the same thing: there is new code on
|
||||
// the branch, so build it.
|
||||
if !matches!(
|
||||
event.action.as_str(),
|
||||
"opened" | "reopened" | "synchronized" | "synchronize" | "edited"
|
||||
) {
|
||||
return Err(DeliveryError::UnactedPullRequest(event.action));
|
||||
}
|
||||
if event.pull_request.head.sha.is_empty() {
|
||||
return Err(DeliveryError::NoCommit);
|
||||
}
|
||||
|
||||
// The dialect filters a pull request by the branch it is aimed at, not
|
||||
// the one it comes from, and compiles the workflow at the head commit.
|
||||
let secrets_allowed = event
|
||||
.pull_request
|
||||
.head
|
||||
.repository
|
||||
.as_ref()
|
||||
.is_some_and(|head| head.full_name == event.repository.full_name);
|
||||
Ok(Self {
|
||||
repository: event.repository.full_name,
|
||||
content_repository: None,
|
||||
commit: event.pull_request.head.sha,
|
||||
event: Event::new(
|
||||
EventKind::PullRequest,
|
||||
GitReference::Branch(event.pull_request.base.reference),
|
||||
Vec::new(),
|
||||
),
|
||||
pull_request: Some(event.number),
|
||||
changed_files: Some(ChangedFilesRequest::PullRequest(event.number)),
|
||||
workflow: None,
|
||||
delivery: None,
|
||||
principal: event.sender.map(|actor| actor.login),
|
||||
secrets_allowed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Which pull request's file list is still missing, if any. The webhook body
|
||||
/// does not carry it, and a path filter with nothing to admit does not fire
|
||||
/// — so a workflow with `paths:` would silently never run on a pull request.
|
||||
#[must_use]
|
||||
pub fn awaiting_paths(&self) -> Option<ChangedFilesRequest> {
|
||||
self.changed_files.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn touching(mut self, paths: Vec<String>) -> Self {
|
||||
self.event = Event::new(self.event.kind(), self.event.reference().clone(), paths);
|
||||
self.changed_files = None;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn at_commit(mut self, commit: String) -> Self {
|
||||
self.commit = commit;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflow(&self) -> Option<&str> {
|
||||
self.workflow.as_deref()
|
||||
}
|
||||
|
||||
/// Where a run compiled from `workflow` came from. The delivery is the same
|
||||
/// for every workflow the commit carries, so the file is what tells two of
|
||||
/// its runs apart.
|
||||
pub fn origin(&self, workflow: &str) -> Origin {
|
||||
Origin::new(
|
||||
self.repository.clone(),
|
||||
self.commit.clone(),
|
||||
self.reference(),
|
||||
self.event.kind().to_string(),
|
||||
workflow.to_owned(),
|
||||
)
|
||||
.with_delivery(self.delivery.clone())
|
||||
.with_principal(self.principal.clone())
|
||||
.with_secret_trust(self.secrets_allowed)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn identified(mut self, delivery: String) -> Self {
|
||||
self.delivery = Some(delivery);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn selecting(mut self, workflow: Option<String>) -> Self {
|
||||
if workflow.is_some() {
|
||||
self.workflow = workflow;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The reference the commit being built lives on. A pull request head is
|
||||
/// not on a branch this control plane knows, but the forge does publish it
|
||||
/// under the pull request, which is the reference a checkout can fetch.
|
||||
fn reference(&self) -> String {
|
||||
match self.pull_request {
|
||||
Some(number) => format!("refs/pull/{number}/head"),
|
||||
None => self.event.reference().full_name(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn repository(&self) -> &str {
|
||||
&self.repository
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn content_repository(&self) -> &str {
|
||||
self.content_repository
|
||||
.as_deref()
|
||||
.unwrap_or(&self.repository)
|
||||
}
|
||||
|
||||
pub fn using_repository_content(
|
||||
mut self,
|
||||
repository: String,
|
||||
before: String,
|
||||
after: String,
|
||||
) -> Result<Self, DeliveryError> {
|
||||
if uuid::Uuid::parse_str(&repository).is_err()
|
||||
|| !valid_commit_id(&before)
|
||||
|| !valid_commit_id(&after)
|
||||
{
|
||||
return Err(DeliveryError::InvalidNativeField(
|
||||
"repository content selector",
|
||||
));
|
||||
}
|
||||
self.content_repository = Some(repository);
|
||||
self.changed_files = Some(ChangedFilesRequest::Commits {
|
||||
before: Some(before),
|
||||
after,
|
||||
});
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn commit(&self) -> &str {
|
||||
&self.commit
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn event(&self) -> &Event {
|
||||
&self.event
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_commit_id(value: &str) -> bool {
|
||||
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
use serde::Deserialize;
|
||||
use syncode_control_runs::Origin;
|
||||
use syncode_workflow::{Event, EventKind, GitReference};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::sources::ChangedFilesRequest;
|
||||
|
||||
/// One thing the repository event source says happened, in the shape deciding needs: which
|
||||
/// repository, at which commit, and what the event was.
|
||||
///
|
||||
/// The event source speaks about branches and tags as full references. Everything
|
||||
/// past this boundary keeps the reference kind while using its short name, because
|
||||
/// that is what a workflow writes in its filters.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Delivery {
|
||||
repository: String,
|
||||
content_repository: Option<String>,
|
||||
commit: String,
|
||||
event: Event,
|
||||
/// Which pull request this came from. It names the reference the commit
|
||||
/// lives on, which no branch does for a pull request.
|
||||
pull_request: Option<u64>,
|
||||
checkout_reference: Option<String>,
|
||||
changed_files: Option<ChangedFilesRequest>,
|
||||
workflow: Option<String>,
|
||||
delivery: Option<String>,
|
||||
principal: Option<String>,
|
||||
secrets_allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DeliveryError {
|
||||
#[error("the repository event source sent a body that is not a known event: {0}")]
|
||||
Unreadable(#[from] serde_json::Error),
|
||||
|
||||
#[error("the repository event source sent event {0:?}, which does not trigger runs")]
|
||||
Unsupported(String),
|
||||
|
||||
#[error("the event names an unsupported repository reference {0:?}")]
|
||||
NotARepositoryReference(String),
|
||||
|
||||
#[error("the event carries no commit to compile from")]
|
||||
NoCommit,
|
||||
|
||||
#[error("the requested event carries no workflow to compile")]
|
||||
NoWorkflow,
|
||||
|
||||
#[error(
|
||||
"the repository event source sent a pull request {0:?} this control plane does not act on"
|
||||
)]
|
||||
UnactedPullRequest(String),
|
||||
|
||||
#[error("the repository event uses unsupported protocol version {0:?}")]
|
||||
UnsupportedProtocol(String),
|
||||
|
||||
#[error("the repository event uses unsupported schema version {0}")]
|
||||
UnsupportedSchema(u32),
|
||||
|
||||
#[error("the repository event body names message type {0:?}")]
|
||||
UnexpectedMessageType(String),
|
||||
|
||||
#[error("the repository event carries invalid {0}")]
|
||||
InvalidNativeField(&'static str),
|
||||
}
|
||||
|
||||
/// The push payload, narrowed to the fields a decision needs. Everything else
|
||||
/// the event source sends is ignored on purpose: fields we do not read cannot
|
||||
/// break us when the source adds or renames them.
|
||||
#[derive(Deserialize)]
|
||||
struct Push {
|
||||
#[serde(rename = "ref")]
|
||||
reference: String,
|
||||
after: String,
|
||||
#[serde(default)]
|
||||
head_commit: Option<HeadCommit>,
|
||||
repository: Repository,
|
||||
#[serde(default)]
|
||||
commits: Vec<Commit>,
|
||||
#[serde(default)]
|
||||
sender: Option<Actor>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HeadCommit {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Repository {
|
||||
full_name: String,
|
||||
}
|
||||
|
||||
/// The pull request payload, narrowed the same way the push one is.
|
||||
#[derive(Deserialize)]
|
||||
struct PullRequestEvent {
|
||||
action: String,
|
||||
number: u64,
|
||||
pull_request: PullRequestBody,
|
||||
repository: Repository,
|
||||
#[serde(default)]
|
||||
sender: Option<Actor>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Actor {
|
||||
login: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PullRequestBody {
|
||||
head: Reference,
|
||||
base: Reference,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reference {
|
||||
#[serde(default)]
|
||||
sha: String,
|
||||
#[serde(rename = "ref", default)]
|
||||
reference: String,
|
||||
#[serde(default, rename = "repo")]
|
||||
repository: Option<Repository>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Commit {
|
||||
#[serde(default)]
|
||||
added: Vec<String>,
|
||||
#[serde(default)]
|
||||
removed: Vec<String>,
|
||||
#[serde(default)]
|
||||
modified: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RequestedEvent {
|
||||
#[serde(rename = "ref")]
|
||||
reference: String,
|
||||
after: String,
|
||||
workflow: String,
|
||||
repository: Repository,
|
||||
#[serde(default)]
|
||||
sender: Option<Actor>,
|
||||
}
|
||||
|
||||
/// A commit that is all zeroes is how the event source says a reference was deleted.
|
||||
/// There is nothing to read a workflow from, so there is nothing to run.
|
||||
const DELETED: &str = "0000000000000000000000000000000000000000";
|
||||
|
||||
const BRANCH_PREFIX: &str = "refs/heads/";
|
||||
const TAG_PREFIX: &str = "refs/tags/";
|
||||
|
||||
fn full_reference(reference: &str) -> Result<GitReference, DeliveryError> {
|
||||
if let Some(branch) = reference.strip_prefix(BRANCH_PREFIX) {
|
||||
return branch_or_error(branch, reference);
|
||||
}
|
||||
if let Some(tag) = reference.strip_prefix(TAG_PREFIX) {
|
||||
return tag_or_error(tag, reference);
|
||||
}
|
||||
Err(DeliveryError::NotARepositoryReference(reference.to_owned()))
|
||||
}
|
||||
|
||||
fn requested_reference(reference: &str) -> Result<GitReference, DeliveryError> {
|
||||
if !reference.starts_with("refs/") {
|
||||
return branch_or_error(reference, reference);
|
||||
}
|
||||
full_reference(reference)
|
||||
}
|
||||
|
||||
fn branch_or_error(name: &str, original: &str) -> Result<GitReference, DeliveryError> {
|
||||
if name.is_empty() {
|
||||
return Err(DeliveryError::NotARepositoryReference(original.to_owned()));
|
||||
}
|
||||
Ok(GitReference::Branch(name.to_owned()))
|
||||
}
|
||||
|
||||
fn tag_or_error(name: &str, original: &str) -> Result<GitReference, DeliveryError> {
|
||||
if name.is_empty() {
|
||||
return Err(DeliveryError::NotARepositoryReference(original.to_owned()));
|
||||
}
|
||||
Ok(GitReference::Tag(name.to_owned()))
|
||||
}
|
||||
|
||||
impl Delivery {
|
||||
pub fn read_all(event: &str, body: &[u8]) -> Result<Vec<Self>, DeliveryError> {
|
||||
match event {
|
||||
"repository.ref.updated" => crate::native_events::read(body).map(|updates| {
|
||||
updates
|
||||
.into_iter()
|
||||
.map(|update| {
|
||||
let changed_files = match &update.reference {
|
||||
GitReference::Branch(_) => Some(ChangedFilesRequest::Commits {
|
||||
before: update.before,
|
||||
after: update.after.clone(),
|
||||
}),
|
||||
GitReference::Tag(_) => None,
|
||||
};
|
||||
Self {
|
||||
repository: update.repository,
|
||||
content_repository: None,
|
||||
commit: update.after,
|
||||
event: Event::new(EventKind::Push, update.reference, Vec::new()),
|
||||
pull_request: None,
|
||||
checkout_reference: None,
|
||||
changed_files,
|
||||
workflow: None,
|
||||
delivery: None,
|
||||
principal: update.principal,
|
||||
secrets_allowed: true,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
"collaboration.pull_request.created"
|
||||
| "collaboration.pull_request.synchronized"
|
||||
| "collaboration.pull_request.state_changed" => crate::collaboration_events::read(body)
|
||||
.map(|update| {
|
||||
vec![Self {
|
||||
repository: update.repository,
|
||||
content_repository: Some(update.content_repository),
|
||||
commit: update.commit.clone(),
|
||||
event: Event::new(
|
||||
EventKind::PullRequest,
|
||||
GitReference::Branch(update.base_branch),
|
||||
Vec::new(),
|
||||
),
|
||||
pull_request: Some(update.number),
|
||||
checkout_reference: Some(
|
||||
GitReference::Branch(update.head_branch).full_name(),
|
||||
),
|
||||
changed_files: Some(ChangedFilesRequest::Commits {
|
||||
before: Some(update.base_commit),
|
||||
after: update.commit,
|
||||
}),
|
||||
workflow: None,
|
||||
delivery: None,
|
||||
principal: Some(update.principal),
|
||||
secrets_allowed: update.trusted,
|
||||
}]
|
||||
}),
|
||||
"push" => Ok(Vec::new()),
|
||||
_ => Self::read(event, body).map(|delivery| vec![delivery]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read what the repository event source delivered. The event name comes
|
||||
/// from the header rather than the body, because that is where the source states
|
||||
/// it; a body alone does not say which kind of event it describes.
|
||||
pub fn read(event: &str, body: &[u8]) -> Result<Self, DeliveryError> {
|
||||
match event {
|
||||
"push" => Self::push(body),
|
||||
"pull_request" => Self::pull_request(body),
|
||||
"workflow_dispatch" => Self::requested(body, EventKind::Manual),
|
||||
"schedule" => Self::requested(body, EventKind::Schedule),
|
||||
other => Err(DeliveryError::Unsupported(other.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
fn requested(body: &[u8], kind: EventKind) -> Result<Self, DeliveryError> {
|
||||
let event: RequestedEvent = serde_json::from_slice(body)?;
|
||||
if event.after == DELETED || event.after.is_empty() {
|
||||
return Err(DeliveryError::NoCommit);
|
||||
}
|
||||
if event.workflow.is_empty() {
|
||||
return Err(DeliveryError::NoWorkflow);
|
||||
}
|
||||
let reference = requested_reference(&event.reference)?;
|
||||
Ok(Self {
|
||||
repository: event.repository.full_name,
|
||||
content_repository: None,
|
||||
commit: event.after,
|
||||
event: Event::new(kind, reference, Vec::new()),
|
||||
pull_request: None,
|
||||
checkout_reference: None,
|
||||
changed_files: None,
|
||||
workflow: Some(event.workflow),
|
||||
delivery: None,
|
||||
principal: event.sender.map(|actor| actor.login),
|
||||
secrets_allowed: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn push(body: &[u8]) -> Result<Self, DeliveryError> {
|
||||
let push: Push = serde_json::from_slice(body)?;
|
||||
if push.after == DELETED || push.after.is_empty() {
|
||||
return Err(DeliveryError::NoCommit);
|
||||
}
|
||||
let reference = full_reference(&push.reference)?;
|
||||
let commit = match &reference {
|
||||
GitReference::Branch(_) => push.after,
|
||||
GitReference::Tag(_) => push
|
||||
.head_commit
|
||||
.map(|head| head.id)
|
||||
.filter(|commit| !commit.is_empty() && commit != DELETED)
|
||||
.ok_or(DeliveryError::NoCommit)?,
|
||||
};
|
||||
|
||||
let mut changed: Vec<String> = push
|
||||
.commits
|
||||
.into_iter()
|
||||
.flat_map(|commit| {
|
||||
commit
|
||||
.added
|
||||
.into_iter()
|
||||
.chain(commit.removed)
|
||||
.chain(commit.modified)
|
||||
})
|
||||
.collect();
|
||||
changed.sort_unstable();
|
||||
changed.dedup();
|
||||
|
||||
Ok(Self {
|
||||
repository: push.repository.full_name,
|
||||
content_repository: None,
|
||||
commit,
|
||||
event: Event::new(EventKind::Push, reference, changed),
|
||||
pull_request: None,
|
||||
checkout_reference: None,
|
||||
changed_files: None,
|
||||
workflow: None,
|
||||
delivery: None,
|
||||
principal: push.sender.map(|actor| actor.login),
|
||||
secrets_allowed: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn pull_request(body: &[u8]) -> Result<Self, DeliveryError> {
|
||||
let event: PullRequestEvent = serde_json::from_slice(body)?;
|
||||
// Closing a pull request does not start work, and the dialect models
|
||||
// reopening and synchronising as the same thing: there is new code on
|
||||
// the branch, so build it.
|
||||
if !matches!(
|
||||
event.action.as_str(),
|
||||
"opened" | "reopened" | "synchronized" | "synchronize" | "edited"
|
||||
) {
|
||||
return Err(DeliveryError::UnactedPullRequest(event.action));
|
||||
}
|
||||
if event.pull_request.head.sha.is_empty() {
|
||||
return Err(DeliveryError::NoCommit);
|
||||
}
|
||||
|
||||
// The dialect filters a pull request by the branch it is aimed at, not
|
||||
// the one it comes from, and compiles the workflow at the head commit.
|
||||
let secrets_allowed = event
|
||||
.pull_request
|
||||
.head
|
||||
.repository
|
||||
.as_ref()
|
||||
.is_some_and(|head| head.full_name == event.repository.full_name);
|
||||
Ok(Self {
|
||||
repository: event.repository.full_name,
|
||||
content_repository: None,
|
||||
commit: event.pull_request.head.sha,
|
||||
event: Event::new(
|
||||
EventKind::PullRequest,
|
||||
GitReference::Branch(event.pull_request.base.reference),
|
||||
Vec::new(),
|
||||
),
|
||||
pull_request: Some(event.number),
|
||||
checkout_reference: None,
|
||||
changed_files: Some(ChangedFilesRequest::PullRequest(event.number)),
|
||||
workflow: None,
|
||||
delivery: None,
|
||||
principal: event.sender.map(|actor| actor.login),
|
||||
secrets_allowed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Which pull request's file list is still missing, if any. The webhook body
|
||||
/// does not carry it, and a path filter with nothing to admit does not fire
|
||||
/// — so a workflow with `paths:` would silently never run on a pull request.
|
||||
#[must_use]
|
||||
pub fn awaiting_paths(&self) -> Option<ChangedFilesRequest> {
|
||||
self.changed_files.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn touching(mut self, paths: Vec<String>) -> Self {
|
||||
self.event = Event::new(self.event.kind(), self.event.reference().clone(), paths);
|
||||
self.changed_files = None;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn at_commit(mut self, commit: String) -> Self {
|
||||
self.commit = commit;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflow(&self) -> Option<&str> {
|
||||
self.workflow.as_deref()
|
||||
}
|
||||
|
||||
/// Where a run compiled from `workflow` came from. The delivery is the same
|
||||
/// for every workflow the commit carries, so the file is what tells two of
|
||||
/// its runs apart.
|
||||
pub fn origin(&self, workflow: &str) -> Origin {
|
||||
Origin::new(
|
||||
self.repository.clone(),
|
||||
self.commit.clone(),
|
||||
self.reference(),
|
||||
self.event.kind().to_string(),
|
||||
workflow.to_owned(),
|
||||
)
|
||||
.with_delivery(self.delivery.clone())
|
||||
.with_principal(self.principal.clone())
|
||||
.with_secret_trust(self.secrets_allowed)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn identified(mut self, delivery: String) -> Self {
|
||||
self.delivery = Some(delivery);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn selecting(mut self, workflow: Option<String>) -> Self {
|
||||
if workflow.is_some() {
|
||||
self.workflow = workflow;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The reference the commit being built lives on. A pull request head is
|
||||
/// not on a branch this control plane knows, but the forge does publish it
|
||||
/// under the pull request, which is the reference a checkout can fetch.
|
||||
fn reference(&self) -> String {
|
||||
if let Some(reference) = &self.checkout_reference {
|
||||
return reference.clone();
|
||||
}
|
||||
match self.pull_request {
|
||||
Some(number) => format!("refs/pull/{number}/head"),
|
||||
None => self.event.reference().full_name(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn repository(&self) -> &str {
|
||||
&self.repository
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn content_repository(&self) -> &str {
|
||||
self.content_repository
|
||||
.as_deref()
|
||||
.unwrap_or(&self.repository)
|
||||
}
|
||||
|
||||
pub fn using_repository_content(
|
||||
mut self,
|
||||
repository: String,
|
||||
before: String,
|
||||
after: String,
|
||||
) -> Result<Self, DeliveryError> {
|
||||
if uuid::Uuid::parse_str(&repository).is_err()
|
||||
|| !valid_commit_id(&before)
|
||||
|| !valid_commit_id(&after)
|
||||
{
|
||||
return Err(DeliveryError::InvalidNativeField(
|
||||
"repository content selector",
|
||||
));
|
||||
}
|
||||
self.content_repository = Some(repository);
|
||||
self.changed_files = Some(ChangedFilesRequest::Commits {
|
||||
before: Some(before),
|
||||
after,
|
||||
});
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn commit(&self) -> &str {
|
||||
&self.commit
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn event(&self) -> &Event {
|
||||
&self.event
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_commit_id(value: &str) -> bool {
|
||||
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
@@ -1,28 +1,29 @@
|
||||
pub mod action_delivery;
|
||||
pub mod action_oci;
|
||||
pub mod action_repository;
|
||||
pub mod action_store;
|
||||
pub mod actions;
|
||||
pub mod actions_read;
|
||||
pub mod actions_read_http;
|
||||
pub mod actions_read_identity;
|
||||
pub mod admin;
|
||||
pub mod check_events;
|
||||
pub mod checks;
|
||||
pub mod events;
|
||||
pub mod intake;
|
||||
pub mod maintenance;
|
||||
mod native_events;
|
||||
#[path = "forge.rs"]
|
||||
pub mod repository;
|
||||
pub mod repository_credentials;
|
||||
pub mod repository_grpc;
|
||||
pub mod repository_sources;
|
||||
pub mod reusable;
|
||||
mod secret_reference_syntax;
|
||||
pub mod secret_references;
|
||||
pub mod secrets;
|
||||
pub mod sources;
|
||||
pub mod token;
|
||||
pub mod trigger;
|
||||
pub mod webhook;
|
||||
pub mod action_delivery;
|
||||
pub mod action_oci;
|
||||
pub mod action_repository;
|
||||
pub mod action_store;
|
||||
pub mod actions;
|
||||
pub mod actions_read;
|
||||
pub mod actions_read_http;
|
||||
pub mod actions_read_identity;
|
||||
pub mod admin;
|
||||
pub mod check_events;
|
||||
pub mod checks;
|
||||
mod collaboration_events;
|
||||
pub mod events;
|
||||
pub mod intake;
|
||||
pub mod maintenance;
|
||||
mod native_events;
|
||||
#[path = "forge.rs"]
|
||||
pub mod repository;
|
||||
pub mod repository_credentials;
|
||||
pub mod repository_grpc;
|
||||
pub mod repository_sources;
|
||||
pub mod reusable;
|
||||
mod secret_reference_syntax;
|
||||
pub mod secret_references;
|
||||
pub mod secrets;
|
||||
pub mod sources;
|
||||
pub mod token;
|
||||
pub mod trigger;
|
||||
pub mod webhook;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,0 +1,133 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::events::DeliveryError;
|
||||
|
||||
const PROTOCOL_VERSION: &str = "1.0";
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
pub(crate) struct PullRequestUpdate {
|
||||
pub repository: String,
|
||||
pub content_repository: String,
|
||||
pub commit: String,
|
||||
pub base_commit: String,
|
||||
pub base_branch: String,
|
||||
pub head_branch: String,
|
||||
pub number: u64,
|
||||
pub principal: String,
|
||||
pub trusted: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
message_id: String,
|
||||
protocol_version: String,
|
||||
schema_version: u32,
|
||||
source_node_id: String,
|
||||
repository_id: String,
|
||||
message_type: String,
|
||||
sequence: i64,
|
||||
term: i64,
|
||||
payload: Payload,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
repository_id: String,
|
||||
head_repository_id: String,
|
||||
number: u64,
|
||||
base_branch: String,
|
||||
base_oid: String,
|
||||
head_branch: String,
|
||||
head_oid: String,
|
||||
actor_id: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
pub(crate) fn read(body: &[u8]) -> Result<PullRequestUpdate, DeliveryError> {
|
||||
let envelope: Envelope = serde_json::from_slice(body)?;
|
||||
validate(&envelope)?;
|
||||
if envelope.payload.state != "open" {
|
||||
return Err(DeliveryError::UnactedPullRequest(envelope.payload.state));
|
||||
}
|
||||
if !valid_uuid(&envelope.payload.repository_id)
|
||||
|| !valid_uuid(&envelope.payload.head_repository_id)
|
||||
|| !valid_uuid(&envelope.payload.actor_id)
|
||||
|| envelope.payload.number == 0
|
||||
|| envelope.payload.base_branch.is_empty()
|
||||
|| envelope.payload.head_branch.is_empty()
|
||||
|| !valid_object_id(&envelope.payload.base_oid)
|
||||
|| !valid_object_id(&envelope.payload.head_oid)
|
||||
{
|
||||
return Err(DeliveryError::InvalidNativeField(
|
||||
"collaboration pull request",
|
||||
));
|
||||
}
|
||||
Ok(PullRequestUpdate {
|
||||
repository: envelope.payload.repository_id.clone(),
|
||||
content_repository: envelope.payload.head_repository_id.clone(),
|
||||
commit: envelope.payload.head_oid.clone(),
|
||||
base_commit: envelope.payload.base_oid,
|
||||
base_branch: envelope.payload.base_branch,
|
||||
head_branch: envelope.payload.head_branch,
|
||||
number: envelope.payload.number,
|
||||
principal: envelope.payload.actor_id,
|
||||
trusted: envelope.payload.repository_id == envelope.payload.head_repository_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(envelope: &Envelope) -> Result<(), DeliveryError> {
|
||||
if envelope.protocol_version != PROTOCOL_VERSION {
|
||||
return Err(DeliveryError::UnsupportedProtocol(
|
||||
envelope.protocol_version.clone(),
|
||||
));
|
||||
}
|
||||
if envelope.schema_version != SCHEMA_VERSION {
|
||||
return Err(DeliveryError::UnsupportedSchema(envelope.schema_version));
|
||||
}
|
||||
if !matches!(
|
||||
envelope.message_type.as_str(),
|
||||
"collaboration.pull_request.created"
|
||||
| "collaboration.pull_request.synchronized"
|
||||
| "collaboration.pull_request.state_changed"
|
||||
) {
|
||||
return Err(DeliveryError::UnexpectedMessageType(
|
||||
envelope.message_type.clone(),
|
||||
));
|
||||
}
|
||||
if !valid_uuid(&envelope.message_id)
|
||||
|| !valid_uuid(&envelope.source_node_id)
|
||||
|| !valid_uuid(&envelope.repository_id)
|
||||
|| envelope.sequence <= 0
|
||||
|| envelope.term <= 0
|
||||
{
|
||||
return Err(DeliveryError::InvalidNativeField(
|
||||
"collaboration event envelope",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_uuid(value: &str) -> bool {
|
||||
uuid::Uuid::parse_str(value).is_ok()
|
||||
}
|
||||
|
||||
fn valid_object_id(value: &str) -> bool {
|
||||
matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::read;
|
||||
|
||||
#[test]
|
||||
fn reads_an_open_native_pull_request() -> Result<(), crate::events::DeliveryError> {
|
||||
let body = br#"{"message_id":"018f47e2-b2c4-7f19-8a6d-13ef76c89211","protocol_version":"1.0","schema_version":1,"source_node_id":"018f47e2-b2c4-7f19-8a6d-13ef76c89212","repository_id":"018f47e2-b2c4-7f19-8a6d-13ef76c89214","message_type":"collaboration.pull_request.created","sequence":1,"term":1,"payload":{"repository_id":"018f47e2-b2c4-7f19-8a6d-13ef76c89214","head_repository_id":"018f47e2-b2c4-7f19-8a6d-13ef76c89214","number":3,"base_branch":"develop","base_oid":"1111111111111111111111111111111111111111","head_branch":"feature","head_oid":"2222222222222222222222222222222222222222","actor_id":"018f47e2-b2c4-7f19-8a6d-13ef76c89213","state":"open"}}"#;
|
||||
|
||||
let event = read(body)?;
|
||||
|
||||
assert_eq!(event.number, 3);
|
||||
assert_eq!(event.head_branch, "feature");
|
||||
assert!(event.trusted);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user