feat: Complete native Actions delivery #53

Manually merged
day01 merged 9 commits from feat/0.6-verify-dynamic-git into develop 2026-08-31 07:27:18 +00:00
4 changed files with 199 additions and 0 deletions
Showing only changes of commit f4879d4c45 - Show all commits
+35
View File
@@ -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
View File
@@ -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;
+30
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -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(())
}
}