mirror of
https://github.com/encounter/decomp.dev.git
synced 2026-07-10 03:18:48 -07:00
410 lines
16 KiB
Rust
410 lines
16 KiB
Rust
use std::fmt::Display;
|
|
|
|
use anyhow::{Context, Result};
|
|
use axum::{
|
|
body::Bytes,
|
|
extract::{FromRef, FromRequest, Request},
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
};
|
|
use decomp_dev_core::{AppError, config::GitHubConfig};
|
|
use decomp_dev_db::Database;
|
|
use hmac::{Hmac, Mac};
|
|
use octocrab::{
|
|
Octocrab,
|
|
models::{
|
|
pulls::PullRequest,
|
|
webhook_events::{
|
|
EventInstallation, WebhookEvent, WebhookEventPayload,
|
|
payload::{
|
|
InstallationWebhookEventAction, PullRequestWebhookEventAction,
|
|
WorkflowRunWebhookEventAction,
|
|
},
|
|
},
|
|
workflows::{Run, WorkFlow},
|
|
},
|
|
};
|
|
use sha2::Sha256;
|
|
|
|
use crate::{
|
|
GitHub, ProcessWorkflowRunResult,
|
|
changes::{generate_changes, generate_comment},
|
|
commit_from_head_commit, process_workflow_run,
|
|
};
|
|
|
|
#[derive(Clone)]
|
|
pub struct WebhookState {
|
|
pub config: GitHubConfig,
|
|
pub db: Database,
|
|
pub github: GitHub,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
pub struct RunWithPullRequests {
|
|
#[serde(flatten)]
|
|
pub inner: Run,
|
|
pub pull_requests: Vec<PullRequest>,
|
|
}
|
|
|
|
pub async fn webhook(GitHubEvent { event, state }: GitHubEvent) -> Result<Response, AppError> {
|
|
let Some(installations) = &state.github.installations else {
|
|
tracing::warn!("Received webhook event {:?} with no GitHub app config", event.kind);
|
|
return Ok((StatusCode::OK, "No app config").into_response());
|
|
};
|
|
let mut owner = None;
|
|
if let Some(repository) = event.repository {
|
|
owner = repository.owner.map(|o| o.login.clone());
|
|
if let Some(full_name) = repository.full_name {
|
|
tracing::warn!("Received webhook event {:?} from repository {}", event.kind, full_name);
|
|
} else {
|
|
tracing::warn!(
|
|
"Received webhook event {:?} from repository ID {}",
|
|
event.kind,
|
|
repository.id.0
|
|
);
|
|
}
|
|
} else if let Some(organization) = event.organization {
|
|
owner = Some(organization.login.clone());
|
|
tracing::warn!("Received webhook event {:?} from org {}", event.kind, organization.login);
|
|
} else if let Some(sender) = event.sender {
|
|
tracing::warn!("Received webhook event {:?} from @{}", event.kind, sender.login);
|
|
} else {
|
|
tracing::warn!("Received webhook event {:?} from unknown source", event.kind);
|
|
}
|
|
let installation_id = match event.installation {
|
|
Some(EventInstallation::Full(installation)) => {
|
|
owner = Some(installation.account.login.clone());
|
|
Some(installation.id)
|
|
}
|
|
Some(EventInstallation::Minimal(installation)) => Some(installation.id),
|
|
None => None,
|
|
};
|
|
let client = if let Some(installation_id) = installation_id {
|
|
let mut installations = installations.lock().await;
|
|
installations.client_for_installation(installation_id).await?
|
|
} else {
|
|
state.github.client.clone()
|
|
};
|
|
match event.specific {
|
|
WebhookEventPayload::WorkflowRun(inner) => {
|
|
if inner.action == WorkflowRunWebhookEventAction::Completed {
|
|
let Some(workflow) = inner.workflow else {
|
|
tracing::error!("Received workflow_run event with no workflow");
|
|
return Ok((StatusCode::OK, "No workflow run").into_response());
|
|
};
|
|
let workflow: WorkFlow = match serde_json::from_value(workflow) {
|
|
Ok(workflow) => workflow,
|
|
Err(e) => {
|
|
tracing::error!("Received workflow_run event with invalid workflow: {e}");
|
|
return Ok((StatusCode::OK, "Invalid workflow").into_response());
|
|
}
|
|
};
|
|
let workflow_run: RunWithPullRequests =
|
|
match serde_json::from_value(inner.workflow_run) {
|
|
Ok(workflow_run) => workflow_run,
|
|
Err(e) => {
|
|
tracing::error!(
|
|
"Received workflow_run event with invalid workflow_run: {e}"
|
|
);
|
|
return Ok((StatusCode::OK, "Invalid workflow run").into_response());
|
|
}
|
|
};
|
|
if let Err(e) =
|
|
handle_workflow_run_completed(&state, client, workflow, workflow_run).await
|
|
{
|
|
tracing::error!("Error handling workflow_run event: {e}");
|
|
return Ok((StatusCode::OK, "Internal error").into_response());
|
|
}
|
|
}
|
|
}
|
|
WebhookEventPayload::PullRequest(inner) => {
|
|
if inner.action == PullRequestWebhookEventAction::Opened
|
|
|| inner.action == PullRequestWebhookEventAction::Synchronize
|
|
{
|
|
if let Err(e) = handle_pull_request_update(&state, client, inner.pull_request).await
|
|
{
|
|
tracing::error!("Error handling pull_request event: {e}");
|
|
return Ok((StatusCode::OK, "Internal error").into_response());
|
|
}
|
|
}
|
|
}
|
|
WebhookEventPayload::Installation(inner) => {
|
|
tracing::info!(
|
|
"Installation {:?} for {}",
|
|
inner.action,
|
|
owner.as_deref().unwrap_or("[unknown]")
|
|
);
|
|
match inner.action {
|
|
InstallationWebhookEventAction::Created => {
|
|
// Installation client is already created
|
|
}
|
|
InstallationWebhookEventAction::Deleted => {
|
|
// Remove the installation client
|
|
let mut installations = installations.lock().await;
|
|
if let Some(installation_id) = installation_id {
|
|
installations.repo_to_installation.retain(|_, v| *v != installation_id);
|
|
installations.clients.remove(&installation_id);
|
|
} else {
|
|
tracing::warn!(
|
|
"Received installation deleted event with no installation ID"
|
|
);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
WebhookEventPayload::InstallationRepositories(inner) => {
|
|
tracing::info!(
|
|
"Installation {:?} for {} repositories changed",
|
|
inner.action,
|
|
owner.as_deref().unwrap_or("[unknown]")
|
|
);
|
|
let Some(installation_id) = installation_id else {
|
|
tracing::warn!("Received installation_repositories event with no installation ID");
|
|
return Ok((StatusCode::OK, "No installation ID").into_response());
|
|
};
|
|
let mut installations = installations.lock().await;
|
|
for repository in &inner.repositories_added {
|
|
tracing::info!("Added repository {}", repository.full_name);
|
|
installations
|
|
.repo_to_installation
|
|
.insert(repository.id.into_inner(), installation_id);
|
|
}
|
|
if !inner.repositories_removed.is_empty() {
|
|
for repository in &inner.repositories_removed {
|
|
tracing::info!("Removed repository {}", repository.full_name);
|
|
}
|
|
installations.repo_to_installation.retain(|repo, id| {
|
|
if *id != installation_id {
|
|
return true;
|
|
}
|
|
inner.repositories_removed.iter().any(|r| r.id.into_inner() == *repo)
|
|
});
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok((StatusCode::OK, "Event processed").into_response())
|
|
}
|
|
|
|
async fn handle_workflow_run_completed(
|
|
state: &WebhookState,
|
|
client: Octocrab,
|
|
_workflow: WorkFlow,
|
|
workflow_run: RunWithPullRequests,
|
|
) -> Result<()> {
|
|
let RunWithPullRequests { inner: workflow_run, mut pull_requests } = workflow_run;
|
|
let repository_id = workflow_run.repository.id.into_inner();
|
|
let Some(project_info) = state.db.get_project_info_by_id(repository_id, None).await? else {
|
|
tracing::warn!("No project found for repository ID {}", repository_id);
|
|
return Ok(());
|
|
};
|
|
let repository =
|
|
client.repos_by_id(repository_id).get().await.context("Failed to fetch repository")?;
|
|
let ProcessWorkflowRunResult { artifacts } =
|
|
process_workflow_run(&client, &project_info.project, workflow_run.id).await?;
|
|
tracing::debug!(
|
|
"Processed workflow run {} ({}) (artifacts {})",
|
|
workflow_run.id,
|
|
workflow_run.head_sha,
|
|
artifacts.len()
|
|
);
|
|
if artifacts.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let commit = commit_from_head_commit(&workflow_run.head_commit);
|
|
if workflow_run.event == "push"
|
|
&& match &repository.default_branch {
|
|
Some(default_branch) => default_branch == &workflow_run.head_branch,
|
|
None => matches!(workflow_run.head_branch.as_str(), "master" | "main"),
|
|
}
|
|
{
|
|
// Insert reports into the database
|
|
for artifact in artifacts {
|
|
let start = std::time::Instant::now();
|
|
state
|
|
.db
|
|
.insert_report(&project_info.project, &commit, &artifact.version, *artifact.report)
|
|
.await?;
|
|
let duration = start.elapsed();
|
|
tracing::info!(
|
|
"Inserted report {} ({}) in {}ms",
|
|
artifact.version,
|
|
commit.sha,
|
|
duration.as_millis()
|
|
);
|
|
}
|
|
} else if workflow_run.event == "pull_request" {
|
|
if !project_info.project.enable_pr_comments {
|
|
return Ok(());
|
|
}
|
|
// Actions pull_request builds always merge with the latest commit on the base branch.
|
|
// We can't use the base commit from the workflow run or pull request APIs, those are
|
|
// both lies. For simplicity, we'll just always compare against the latest commit that
|
|
// we have stored.
|
|
let Some(base_commit) = project_info.commit else {
|
|
tracing::warn!("No base commit found for repository ID {}", repository_id);
|
|
return Ok(());
|
|
};
|
|
// Fetch any associated pull requests
|
|
if pull_requests.is_empty() {
|
|
let head = if let Some(head_owner) =
|
|
workflow_run.head_repository.as_ref().and_then(|r| r.owner.as_ref())
|
|
{
|
|
format!("{}:{}", head_owner.login, workflow_run.head_branch)
|
|
} else {
|
|
workflow_run.head_branch.clone()
|
|
};
|
|
pull_requests = client
|
|
.all_pages(
|
|
client
|
|
.pulls(&project_info.project.owner, &project_info.project.repo)
|
|
.list()
|
|
.head(&head)
|
|
.send()
|
|
.await?,
|
|
)
|
|
.await?;
|
|
tracing::info!("Found {} pull requests for {}", pull_requests.len(), head);
|
|
}
|
|
for pull_request in pull_requests {
|
|
if pull_request.head.sha != workflow_run.head_sha {
|
|
tracing::warn!(
|
|
"Pull request {} head SHA {} does not match workflow run head SHA {}",
|
|
pull_request.id,
|
|
pull_request.head.sha,
|
|
workflow_run.head_sha
|
|
);
|
|
continue;
|
|
}
|
|
if repository.default_branch.as_ref().is_none_or(|b| pull_request.base.ref_field != *b)
|
|
{
|
|
tracing::warn!(
|
|
"Pull request {} base branch {} does not match default branch {}",
|
|
pull_request.id,
|
|
pull_request.base.ref_field,
|
|
repository.default_branch.as_deref().unwrap_or("[unknown]")
|
|
);
|
|
continue;
|
|
}
|
|
tracing::info!("Processing pull request {}", pull_request.id);
|
|
let issues = client.issues_by_id(repository_id);
|
|
// Only fetch first page for now
|
|
let existing_comments = issues.list_comments(pull_request.number).send().await?;
|
|
for artifact in &artifacts {
|
|
let Some(cached_report) = state
|
|
.db
|
|
.get_report(
|
|
&project_info.project.owner,
|
|
&project_info.project.repo,
|
|
&base_commit.sha,
|
|
&artifact.version,
|
|
)
|
|
.await?
|
|
else {
|
|
tracing::warn!(
|
|
"No report found for pull request {} (base {}) and version {}",
|
|
pull_request.id,
|
|
pull_request.base.sha,
|
|
artifact.version
|
|
);
|
|
continue;
|
|
};
|
|
let report_file = state.db.upgrade_report(&cached_report).await?;
|
|
let report = report_file.report.flatten();
|
|
let changes = generate_changes(&report, &artifact.report)?;
|
|
let comment_text = generate_comment(&report_file, artifact, &commit, changes);
|
|
let existing_comment = existing_comments
|
|
.items
|
|
.iter()
|
|
.find(|comment| {
|
|
// TODO check author ID
|
|
comment.body.as_ref().is_some_and(|body| {
|
|
body.contains(format!("Report for {}", artifact.version).as_str())
|
|
})
|
|
})
|
|
.map(|comment| comment.id);
|
|
if let Some(existing_comment) = existing_comment {
|
|
issues
|
|
.update_comment(existing_comment, comment_text)
|
|
.await
|
|
.context("Failed to update existing comment")?;
|
|
} else {
|
|
issues
|
|
.create_comment(pull_request.number, comment_text)
|
|
.await
|
|
.context("Failed to create comment")?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_pull_request_update(
|
|
_state: &WebhookState,
|
|
_client: Octocrab,
|
|
_pull_request: PullRequest,
|
|
) -> Result<()> {
|
|
// Handle the pull request update event here
|
|
// tracing::info!("Pull request updated: {:?}", pull_request);
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify and extract GitHub Event Payload.
|
|
#[derive(Clone)]
|
|
#[must_use]
|
|
pub struct GitHubEvent {
|
|
pub event: WebhookEvent,
|
|
pub state: WebhookState,
|
|
}
|
|
|
|
impl<S> FromRequest<S> for GitHubEvent
|
|
where
|
|
WebhookState: FromRef<S>,
|
|
S: Send + Sync + Clone,
|
|
{
|
|
type Rejection = Response;
|
|
|
|
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
|
fn err(m: impl Display) -> Response {
|
|
tracing::error!("{m}");
|
|
(StatusCode::BAD_REQUEST, m.to_string()).into_response()
|
|
}
|
|
let event = req
|
|
.headers()
|
|
.get("X-GitHub-Event")
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(|| err("X-GitHub-Event header missing"))?
|
|
.to_string();
|
|
let inner = WebhookState::from_ref(state);
|
|
let body = if let Some(app_config) = &inner.config.app {
|
|
let signature_sha256 = req
|
|
.headers()
|
|
.get("X-Hub-Signature-256")
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(|| err("X-Hub-Signature-256 missing"))?
|
|
.strip_prefix("sha256=")
|
|
.ok_or_else(|| err("X-Hub-Signature-256 sha256= prefix missing"))?;
|
|
let signature =
|
|
hex::decode(signature_sha256).map_err(|_| err("X-Hub-Signature-256 malformed"))?;
|
|
let body =
|
|
Bytes::from_request(req, state).await.map_err(|_| err("error reading body"))?;
|
|
let mut mac = Hmac::<Sha256>::new_from_slice(app_config.webhook_secret.as_bytes())
|
|
.expect("HMAC can take key of any size");
|
|
mac.update(&body);
|
|
if mac.verify_slice(&signature).is_err() {
|
|
return Err(err("signature mismatch"));
|
|
}
|
|
body
|
|
} else {
|
|
Bytes::from_request(req, state).await.map_err(|_| err("error reading body"))?
|
|
};
|
|
let value = WebhookEvent::try_from_header_and_body(&event, &body)
|
|
.map_err(|_| err("error parsing body"))?;
|
|
Ok(GitHubEvent { event: value, state: inner })
|
|
}
|
|
}
|