From d0cdbf55a3a278ce21cab11885170e9f4a6d4094 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 21 Jun 2024 07:38:14 +0200 Subject: [PATCH 1/3] feat: create PR comments --- Cargo.lock | 54 ++ Cargo.toml | 3 + README.md | 70 ++- resources/screenshotCode.png | Bin 0 -> 44256 bytes resources/screenshotJUnit.png | Bin 0 -> 48349 bytes src/app.rs | 300 +++++++++- src/artifact_api.rs | 565 ++++++++++++++++-- src/cache.rs | 12 +- src/config.rs | 15 +- src/error.rs | 8 +- src/query.rs | 12 +- ...rtifactview__app__tests__pr_comment_1.snap | 9 + ...rtifactview__app__tests__pr_comment_2.snap | 14 + ...rtifactview__app__tests__pr_comment_3.snap | 15 + src/util.rs | 20 +- tests/testfiles/giteaWorkflowRun.json | 320 ++++++++++ tests/testfiles/githubWorkflowRun.json | 220 +++++++ 17 files changed, 1560 insertions(+), 77 deletions(-) create mode 100644 resources/screenshotCode.png create mode 100644 resources/screenshotJUnit.png create mode 100644 src/snapshots/artifactview__app__tests__pr_comment_1.snap create mode 100644 src/snapshots/artifactview__app__tests__pr_comment_2.snap create mode 100644 src/snapshots/artifactview__app__tests__pr_comment_3.snap create mode 100644 tests/testfiles/giteaWorkflowRun.json create mode 100644 tests/testfiles/githubWorkflowRun.json diff --git a/Cargo.lock b/Cargo.lock index d6d6299..b444204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,7 @@ dependencies = [ "reqwest", "rstest", "scraper", + "secrecy", "serde", "serde-env", "serde-hex", @@ -180,11 +181,13 @@ dependencies = [ "syntect", "temp_testdir", "thiserror", + "time", "tokio", "tokio-util", "tower-http", "tracing", "tracing-subscriber", + "unic-emoji-char", "url", "yarte", "yarte_helpers", @@ -2468,6 +2471,16 @@ dependencies = [ "tendril", ] +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.0" @@ -3132,6 +3145,47 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-emoji-char" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b07221e68897210270a38bde4babb655869637af0f69407f96053a34f76494d" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicase" version = "2.7.0" diff --git a/Cargo.toml b/Cargo.toml index 9197504..b4cbbc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ reqwest = { version = "0.12.4", default-features = false, features = [ "json", "stream", ] } +secrecy = { version = "0.8.0", features = ["serde"] } serde = { version = "1.0.203", features = ["derive"] } serde-env = "0.1.1" serde-hex = "0.1.0" @@ -65,11 +66,13 @@ syntect = { version = "5.2.0", default-features = false, features = [ "regex-onig", ] } thiserror = "1.0.61" +time = { version = "0.3.36", features = ["serde-human-readable", "macros"] } tokio = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] } tokio-util = { version = "0.7.11", features = ["io"] } tower-http = { version = "0.5.2", features = ["trace", "set-header"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" +unic-emoji-char = "0.9.0" url = "2.5.0" yarte = { version = "0.15.7", features = ["json"] } diff --git a/README.md b/README.md index 0a610f8..d3458fd 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,28 @@ # Artifactview -View CI build artifacts from Forgejo/Github using your web browser. +View CI build artifacts from Forgejo/Github using your web browser! Forgejo and GitHub's CI systems allow you to upload files and directories as [artifacts](https://github.com/actions/upload-artifact). These can be downloaded as zip files. However there is no simple way to view individual files of an artifact. -Artifactview is a small web application that fetches these CI artifacts and displays -their contents. +That's why I developed Artifactview. It is a small web application that fetches these CI +artifacts and serves their contents. -It offers full support for single page applications and custom 404 error pages. -Single-page applications require a file named `200.html` placed in the root directory, -which will be served in case no file exists for the requested path. A custom 404 error -page is defined using a file named `404.html` in the root directory. +It is a valuable tool in open source software development: you can quickly look at test +reports or coverage data or showcase your single page web applications to your +teammates. -Artifactview displays a file listing if there is no `index.html` or fallback page -present, so you can browse artifacts that dont contain websites. +## Features -![Artifact file listing](resources/screenshotFiles.png) +- 📦 Quickly view CI artifacts in your browser without messing with zip files +- 📂 File listing for directories without index page +- 🏠 Every artifact has a unique subdomain to support pages with absolute paths +- 🌎 Full SPA support with `200.html` and `404.html` fallback pages +- 👁️ Viewer for Markdown, syntax-highlighted code and JUnit test reports +- 🐵 Greasemonkey userscript to automatically add a "View artifact" button to + GitHub/Gitea/Forgejo +- 🦀 Fast and efficient, only extracts files from zip archive if necessary ## How to use @@ -27,6 +32,51 @@ box on the main page. You can also pass the run URL with the `?url=` parameter. Artifactview will show you a selection page where you will be able to choose the artifact you want to browse. +If there is no `index.html` or fallback page present, a file listing will be shown so +you can browse the contents of the artifact. + +![Artifact file listing](resources/screenshotFiles.png) + +If you want to use Artifactview to showcase a static website, you can make use of +fallback pages. If a file named `200.html` is placed in the root directory, it will be +served in case no file exists for the requested path. This allows serving single-page +applications with custom routing. A custom 404 error page is defined using a file named +`404.html` in the root directory. + +The behavior is the same as with other web hosts like surge.sh, so a lot of website +build tools already follow that convention. + +Artifactview includes different viewers to better display files of certain types that +browsers cannot handle by default. There is a renderer for markdown files as well as a +syntax highlighter for source code files. The viewers are only shown if the files are +accessed with the `?viewer=` URL parameter which is automatically set when opening a +file from a directory listing. You can always download the raw version of the file via +the link in the top right corner. + +![Code viewer](resources/screenshotCode.png) + +Artifactview even includes an interactive viewer for JUnit test reports (XML files with +`junit` in their filename). The application has been designed to be easily extendable, +so if you have suggestions on other viewers that should be added, feel free to create an +issue or a PR. + +![JUnit report viewer](resources/screenshotJUnit.png) + +Accessing Artifactview by copying the CI run URL into its homepage may be a little bit +tedious. That's why there are some convenient alternatives available. + +You can install the Greasemonkey userscript from the link at the bottom of the homepage. +The script adds a "View artifact" link with an eye icon next to every CI artifact on +both GitHub and Forgejo. + +Additionally there is a custom GitHub/Forgejo action that automatically creates a comment +under each pull request with the preview links of all artifacts created by the latest +CI run. This way every collaborator to your project has easy access to the preview. + +```yaml +TODO: showcase action +``` + ## Setup You can run artifactview using the docker image provided under diff --git a/resources/screenshotCode.png b/resources/screenshotCode.png new file mode 100644 index 0000000000000000000000000000000000000000..7052628a573193460b65664ddf602495a69f87d8 GIT binary patch literal 44256 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 literal 0 HcmV?d00001 diff --git a/resources/screenshotJUnit.png b/resources/screenshotJUnit.png new file mode 100644 index 0000000000000000000000000000000000000000..3a050703164ab7af51ed7f1ab11690b0edad43d4 GIT binary patch literal 48349 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 literal 0 HcmV?d00001 diff --git a/src/app.rs b/src/app.rs index 8367c5a..cffecce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,11 @@ use std::{ - collections::BTreeMap, net::SocketAddr, ops::Bound, path::Path, str::FromStr, sync::Arc, + collections::{BTreeMap, HashMap}, + fmt::Write, + net::{IpAddr, SocketAddr}, + ops::Bound, + path::Path, + str::FromStr, + sync::Arc, }; use async_zip::tokio::read::ZipEntryReader; @@ -8,10 +14,11 @@ use axum::{ extract::{Host, Request, State}, http::{Response, Uri}, response::{IntoResponse, Redirect}, - routing::{any, get}, - Router, + routing::{any, get, post}, + Json, RequestExt, Router, }; use futures_lite::AsyncReadExt as LiteAsyncReadExt; +use governor::{Quota, RateLimiter}; use headers::{ContentType, HeaderMapExt}; use http::{HeaderMap, StatusCode}; use serde::Deserialize; @@ -29,7 +36,7 @@ use tower_http::{ }; use crate::{ - artifact_api::ArtifactApi, + artifact_api::{Artifact, ArtifactApi, WorkflowRun}, cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile}, config::Config, error::Error, @@ -52,6 +59,12 @@ struct AppInner { cache: Cache, api: ArtifactApi, viewers: Viewers, + lim_pr_comment: Option< + governor::DefaultKeyedRateLimiter< + IpAddr, + governor::middleware::NoOpMiddleware, + >, + >, } impl Default for App { @@ -65,6 +78,20 @@ struct FileQparams { viewer: Option, } +#[derive(Deserialize)] +struct PrCommentReq { + url: String, + pr: u64, + #[serde(default)] + recreate: bool, + title: Option, + #[serde(default)] + artifact_titles: HashMap, +} + +const DATE_FORMAT: &[time::format_description::FormatItem] = + time::macros::format_description!("[day].[month].[year] [hour]:[minute]:[second]"); + const FAVICON_PATH: &str = "/favicon.ico"; pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -126,6 +153,7 @@ impl App { .route("/.well-known/api/artifacts", get(Self::get_artifacts)) .route("/.well-known/api/artifact", get(Self::get_artifact)) .route("/.well-known/api/files", get(Self::get_files)) + .route("/.well-known/api/prComment", post(Self::pr_comment)) // Prevent access to the .well-known folder since it enables abuse // (e.g. SSL certificate registration by an attacker) .route("/.well-known/*path", any(|| async { Error::Inaccessible })) @@ -331,8 +359,9 @@ impl App { .query() .and_then(|q| serde_urlencoded::from_str::(q).ok()) { - let query = RunQuery::from_forge_url(¶ms.url, &state.i.cfg.load().site_aliases)?; - let artifacts = state.i.api.list(&query).await?; + let query = + RunQuery::from_forge_url_alias(¶ms.url, &state.i.cfg.load().site_aliases)?; + let artifacts = state.i.api.list(&query, true).await?; if artifacts.is_empty() { Err(Error::NotFound("artifacts".into())) @@ -545,7 +574,7 @@ impl App { .typed_header(headers::ContentLength(content_length)) .typed_header( headers::ContentRange::bytes(range, total_len) - .map_err(|e| Error::Internal(e.to_string().into()))?, + .map_err(|e| Error::Other(e.to_string().into()))?, ) .body(Body::from_stream(ReaderStream::new( bufreader.take(content_length), @@ -566,7 +595,7 @@ impl App { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; state.i.cfg.check_filterlist(&query)?; - let artifacts = state.i.api.list(&query.into()).await?; + let artifacts = state.i.api.list(&query.into(), true).await?; Ok(Response::builder().cache().json(&artifacts)?) } @@ -603,6 +632,83 @@ impl App { .json(&files)?) } + /// Create a comment under a workflow's pull request with links to view the artifacts + /// + /// To prevent abuse/spamming, Artifactview will only create a comment if + /// - The workflow is still running + /// - The workflow was triggered by the given pull request + async fn pr_comment( + State(state): State, + request: Request, + ) -> Result { + let ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?; + let req = request + .extract::, _>() + .await + .map_err(|e| Error::BadRequest(e.body_text().into()))?; + let query = RunQuery::from_forge_url(&req.url)?; + + if let Some(limiter) = &state.i.lim_pr_comment { + limiter.check_key(&ip).map_err(Error::from)?; + } + + let run = state.i.api.workflow_run(&query).await?; + if !run.from_pr { + return Err( + Error::BadRequest("workflow run not triggered by pull request".into()).into(), + ); + } + if run.done { + return Err(Error::BadRequest("workflow is not running".into()).into()); + } + if let Some(pr_number) = run.pr_number { + if pr_number != req.pr { + return Err(Error::BadRequest( + format!( + "workflow was triggered by pr#{}, expected: {}", + pr_number, req.pr + ) + .into(), + ) + .into()); + } + } else { + let pr = state.i.api.get_pr(query.as_ref(), req.pr).await?; + if run.head_sha != pr.head.sha { + return Ok(ErrorJson::ok("head of pr does not match workflow run")); + } + } + + let artifacts = match state.i.api.list(&query, false).await { + Ok(a) => a, + Err(Error::NotFound(_)) => return Ok(ErrorJson::ok("no artifacts")), + Err(e) => return Err(e.into()), + }; + let old_comment = state.i.api.find_comment(query.as_ref(), req.pr).await?; + let content = pr_comment_text( + &query, + old_comment.as_ref().map(|c| c.body.as_str()), + &run, + &artifacts, + req.title.as_deref(), + &req.artifact_titles, + &state.i.cfg, + ); + + let c_id = state + .i + .api + .add_comment( + query.as_ref(), + req.pr, + &content, + old_comment.map(|c| c.id), + req.recreate, + ) + .await?; + Ok(ErrorJson::ok(format!("created comment #{c_id}"))) + } + fn favicon() -> Result, Error> { Ok(Response::builder() .typed_header(headers::ContentType::from_str("image/x-icon").unwrap()) @@ -642,10 +748,14 @@ impl AppState { let api = ArtifactApi::new(cfg.clone()); Self { i: Arc::new(AppInner { - cfg, cache, api, viewers: Viewers::new(), + lim_pr_comment: cfg + .load() + .limit_artifacts_per_min + .map(|lim| RateLimiter::keyed(Quota::per_minute(lim))), + cfg, }), } } @@ -689,3 +799,175 @@ fn path_components( } path_components } + +/// Build pull request comment text +#[allow(clippy::assigning_clones)] +fn pr_comment_text( + query: &RunQuery, + old_comment: Option<&str>, + run: &WorkflowRun, + artifacts: &[Artifact], + title: Option<&str>, + artifact_titles: &HashMap, + cfg: &Config, +) -> String { + let mut content = format!("### {} ", title.unwrap_or("Latest build artifacts")); + let mut prevln = "- ".to_owned(); + + let mut prev_builds = None; + let mut np_content = None; + if let Some(old_comment) = old_comment { + prev_builds = util::extract_delim(old_comment, "", ""); + } + + let write_commit = |s: &mut String, sha: &str| { + _ = write!( + s, + "[[{}](https://{}/{}/{}/commit/{})]", + &sha[..10], + query.host, + query.user, + query.repo, + sha + ); + }; + + write_commit(&mut content, &run.head_sha); + write_commit(&mut prevln, &run.head_sha); + _ = content.write_str("\n\n"); + + for a in artifacts.iter().filter(|a| !a.expired) { + // Move leading emoji into a prefix variable since including them in the link does not look good + let mut name_pfx = String::new(); + let mut name = artifact_titles.get(&a.name).unwrap_or(&a.name).to_owned(); + if let Some((i, c)) = name + .char_indices() + .find(|(_, c)| !unic_emoji_char::is_emoji(*c)) + { + if i > 0 && c == ' ' { + name[..i + 1].clone_into(&mut name_pfx); + name = name[i + 1..].to_owned(); + } + } + + let url = cfg.url_with_subdomain(&query.subdomain_with_artifact(a.id)); + // Do not process the same run twice + if np_content.as_ref().is_some_and(|c| c.contains(&url)) { + np_content = None; + } + + _ = writeln!( + &mut content, + r#"{}{}
"#, + name_pfx, url, name, + ); + _ = write!( + &mut prevln, + r#" {},"#, + url, a.name + ); + } + + prevln = prevln.trim_matches([' ', ',']).to_owned(); + if let Some(date_started) = &run.date_started { + _ = write!( + &mut prevln, + " ({} UTC)", + date_started + .to_offset(time::UtcOffset::UTC) + .format(&DATE_FORMAT) + .unwrap_or_default() + ); + } + + if np_content.is_some() || prev_builds.is_some() { + _ = write!( + &mut content, + "

\nPrevious builds\n\n" + ); + if let Some(prev_builds) = prev_builds { + _ = writeln!(&mut content, "{prev_builds}"); + } + if let Some(np_content) = np_content { + _ = writeln!(&mut content, "{np_content}"); + } + _ = writeln!(&mut content, "\n
"); + } else { + _ = writeln!(&mut content, ""); + } + + _ = write!(&mut content, "\ngenerated by [Artifactview {VERSION}](https://codeberg.org/ThetaDev/artifactview)"); + content +} + +#[cfg(test)] +mod tests { + use time::macros::datetime; + + use super::*; + + #[test] + fn pr_comment() { + let mut query = RunQuery::from_forge_url( + "https://code.thetadev.de/ThetaDev/test-actions/actions/runs/104", + ) + .unwrap(); + let artifacts: [Artifact; 3] = [ + Artifact { + id: 1, + name: "Hello".to_owned(), + size: 0, + expired: false, + download_url: String::new(), + user_download_url: None, + }, + Artifact { + id: 2, + name: "Test".to_owned(), + size: 0, + expired: false, + download_url: String::new(), + user_download_url: None, + }, + Artifact { + id: 3, + name: "Expired".to_owned(), + size: 0, + expired: true, + download_url: String::new(), + user_download_url: None, + }, + ]; + let mut artifact_titles = HashMap::new(); + artifact_titles.insert("Hello".to_owned(), "🏠 Hello World ;-)".to_owned()); + let cfg = Config::default(); + + let footer = format!("generated by [Artifactview {VERSION}](https://codeberg.org/ThetaDev/artifactview)"); + + let mut old_comment = None; + for i in 1..=3 { + query.run = i.into(); + let run = WorkflowRun { + head_sha: format!("{i}5eed48a8382513147a949117ef4aa659989d397"), + from_pr: true, + pr_number: None, + date_started: Some(datetime!(2024-06-15 15:30 UTC).replace_hour(i).unwrap()), + done: false, + }; + let comment = pr_comment_text( + &query, + old_comment.as_deref(), + &run, + &artifacts, + None, + &artifact_titles, + &cfg, + ); + let res = comment.replace(&footer, ""); // Remove footer since it depends on the version + insta::assert_snapshot!(format!("pr_comment_{i}"), res); + + old_comment = Some(comment); + } + } +} diff --git a/src/artifact_api.rs b/src/artifact_api.rs index 70885ca..69c6068 100644 --- a/src/artifact_api.rs +++ b/src/artifact_api.rs @@ -1,12 +1,15 @@ //! API-Client to fetch CI artifacts from Github and Forgejo - use std::path::Path; use futures_lite::StreamExt; -use http::header; +use http::{header, Method}; +use once_cell::sync::Lazy; use quick_cache::sync::Cache as QuickCache; +use regex::Regex; use reqwest::{Client, ClientBuilder, IntoUrl, RequestBuilder, Response, Url}; -use serde::{Deserialize, Serialize}; +use secrecy::ExposeSecret; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use time::OffsetDateTime; use tokio::{fs::File, io::AsyncWriteExt}; use crate::{ @@ -19,9 +22,10 @@ pub struct ArtifactApi { http: Client, cfg: Config, qc: QuickCache>, + user_ids: QuickCache, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Artifact { pub id: u64, pub name: String, @@ -35,7 +39,7 @@ pub struct Artifact { pub user_download_url: Option, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] struct GithubArtifact { id: u64, name: String, @@ -44,24 +48,24 @@ struct GithubArtifact { archive_download_url: String, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] struct ForgejoArtifact { name: String, size: u64, status: ForgejoArtifactStatus, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] struct ApiError { message: String, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] struct ArtifactsWrap { artifacts: Vec, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case")] enum ForgejoArtifactStatus { Completed, @@ -100,6 +104,154 @@ impl ForgejoArtifact { } } +#[derive(Debug)] +pub struct WorkflowRun { + pub head_sha: String, + pub from_pr: bool, + pub pr_number: Option, + pub date_started: Option, + pub done: bool, +} + +#[derive(Debug, Deserialize)] +struct ForgejoWorkflowRun { + state: ForgejoWorkflowState, + logs: ForgejoWorkflowLogs, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ForgejoWorkflowState { + run: ForgejoWorkflowStateRun, +} + +#[derive(Debug, Deserialize)] +struct ForgejoWorkflowStateRun { + done: bool, + commit: ForgejoWorkflowCommit, +} + +#[derive(Debug, Deserialize)] +struct ForgejoWorkflowCommit { + link: String, + branch: ForgejoWorkflowBranch, +} + +#[derive(Debug, Deserialize)] +struct ForgejoWorkflowBranch { + link: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ForgejoWorkflowLogs { + steps_log: Vec, +} + +#[derive(Debug, Deserialize)] +struct ForgejoWorkflowLogStep { + started: i64, + lines: Vec, +} + +#[derive(Debug, Deserialize)] +struct LogMessage { + message: String, +} + +#[derive(Debug, Deserialize)] +struct IdEntity { + id: u64, +} + +#[derive(Debug, Deserialize)] +pub struct Comment { + pub id: u64, + pub body: String, + user: IdEntity, +} + +#[derive(Debug, Serialize)] +struct CommentBody<'a> { + body: &'a str, +} + +#[derive(Debug, Deserialize)] +pub struct PullRequest { + pub head: Commit, +} + +#[derive(Debug, Deserialize)] +pub struct Commit { + pub sha: String, +} + +const GITHUB_ACCEPT: &str = "application/vnd.github+json"; +const COMMENT_TAG_PATTERN: &str = ""; + +impl TryFrom for WorkflowRun { + type Error = Error; + + fn try_from(value: ForgejoWorkflowRun) -> Result { + static RE_COMMIT_SHA: Lazy = + Lazy::new(|| Regex::new(r#"^/[\w\-\.]+/[\w\-\.]+/commit/([a-f\d]+)$"#).unwrap()); + static RE_PULL_ID: Lazy = + Lazy::new(|| Regex::new(r#"^/[\w\-\.]+/[\w\-\.]+/pulls/(\d+)$"#).unwrap()); + + let from_pr = value + .logs + .steps_log + .first() + .and_then(|l| l.lines.first()) + .map(|l| l.message.contains("be triggered by event: pull_request")) + .unwrap_or(true); + + Ok(Self { + head_sha: RE_COMMIT_SHA + .captures(&value.state.run.commit.link) + .map(|cap| cap[1].to_string()) + .ok_or(Error::Other( + "could not parse workflow run commit sha".into(), + ))?, + from_pr, + pr_number: if from_pr { + RE_PULL_ID + .captures(&value.state.run.commit.branch.link) + .and_then(|cap| cap[1].parse().ok()) + } else { + None + }, + date_started: value + .logs + .steps_log + .first() + .and_then(|l| OffsetDateTime::from_unix_timestamp(l.started).ok()), + done: value.state.run.done, + }) + } +} + +#[derive(Deserialize)] +struct GitHubWorkflowRun { + head_sha: String, + event: String, + conclusion: Option, + #[serde(with = "time::serde::rfc3339::option")] + run_started_at: Option, +} + +impl From for WorkflowRun { + fn from(value: GitHubWorkflowRun) -> Self { + Self { + head_sha: value.head_sha, + from_pr: value.event == "pull_request", + pr_number: None, + date_started: value.run_started_at, + done: value.conclusion.is_some(), + } + } +} + impl ArtifactApi { pub fn new(cfg: Config) -> Self { Self { @@ -112,26 +264,30 @@ impl ArtifactApi { .build() .unwrap(), qc: QuickCache::new(cfg.load().mem_cache_size), + user_ids: QuickCache::new(50), cfg, } } - pub async fn list(&self, query: &RunQuery) -> Result> { + pub async fn list(&self, query: &RunQuery, cached: bool) -> Result> { let cache_key = query.cache_key(); - self.qc - .get_or_insert_async(&cache_key, async { - let res = if query.is_github() { - self.list_github(query.as_ref()).await - } else { - self.list_forgejo(query.as_ref()).await - }; - if res.as_ref().is_ok_and(|v| v.is_empty()) { - Err(Error::NotFound("artifact".into())) - } else { - res - } - }) - .await + let fut = async { + let res = if query.is_github() { + self.list_github(query.as_ref()).await + } else { + self.list_forgejo(query.as_ref()).await + }; + if res.as_ref().is_ok_and(|v| v.is_empty()) { + Err(Error::NotFound("artifact".into())) + } else { + res + } + }; + if cached { + self.qc.get_or_insert_async(&cache_key, fut).await + } else { + fut.await + } } pub async fn fetch(&self, query: &ArtifactQuery) -> Result { @@ -177,7 +333,7 @@ impl ArtifactApi { let url = Url::parse(&artifact.download_url)?; let req = if url.domain() == Some("api.github.com") { - self.get_github(url) + self.get_github_any(url) } else { self.http.get(url) }; @@ -212,8 +368,7 @@ impl ArtifactApi { ); let resp = self - .http - .get(url) + .get_forgejo(url) .send() .await? .error_for_status()? @@ -236,10 +391,8 @@ impl ArtifactApi { query.user, query.repo, query.run ); - let resp = Self::handle_github_error(self.get_github(url).send().await?) - .await? - .json::>() - .await?; + let resp = + Self::send_api_req::>(self.get_github(url)).await?; Ok(resp .artifacts @@ -254,14 +407,12 @@ impl ArtifactApi { query.user, query.repo, query.artifact ); - let artifact = Self::handle_github_error(self.get_github(url).send().await?) - .await? - .json::() - .await?; + let artifact = Self::send_api_req::(self.get_github(url)).await?; Ok(artifact.into_artifact(query.as_ref())) } - async fn handle_github_error(resp: Response) -> Result { + async fn send_api_req_empty(req: RequestBuilder) -> Result { + let resp = req.send().await?; if let Err(e) = resp.error_for_status_ref() { let status = resp.status(); let msg = resp.json::().await.ok(); @@ -274,21 +425,330 @@ impl ArtifactApi { } } - fn get_github(&self, url: U) -> RequestBuilder { + async fn send_api_req(req: RequestBuilder) -> Result { + Ok(Self::send_api_req_empty(req).await?.json().await?) + } + + fn get_github_any(&self, url: U) -> RequestBuilder { let mut builder = self.http.get(url); if let Some(github_token) = &self.cfg.load().github_token { - builder = builder.header(header::AUTHORIZATION, format!("Bearer {github_token}")); + builder = builder.header( + header::AUTHORIZATION, + format!("Bearer {}", github_token.expose_secret()), + ); } builder } + + fn get_github(&self, url: U) -> RequestBuilder { + self.get_github_any(url) + .header(header::ACCEPT, GITHUB_ACCEPT) + } + + /// Authorized GitHub request + fn req_github(&self, method: Method, url: U) -> Result { + Ok(self + .http + .request(method, url) + .header(header::ACCEPT, GITHUB_ACCEPT) + .header(header::CONTENT_TYPE, GITHUB_ACCEPT) + .header( + header::AUTHORIZATION, + format!( + "Bearer {}", + self.cfg + .load() + .github_token + .as_ref() + .map(ExposeSecret::expose_secret) + .ok_or(Error::Other("GitHub token required".into()))? + ), + )) + } + + fn get_forgejo(&self, url: U) -> RequestBuilder { + self.http + .get(url) + .header(header::ACCEPT, mime::APPLICATION_JSON.essence_str()) + } + + /// Authorized Forgejo request + fn req_forgejo(&self, method: Method, url: U) -> Result { + let u = url.into_url()?; + let host = u.host_str().ok_or(Error::InvalidUrl)?; + let token = self + .cfg + .load() + .forgejo_tokens + .get(host) + .ok_or_else(|| Error::Other(format!("Forgejo token for {host} required").into()))? + .expose_secret(); + Ok(self + .http + .request(method, u) + .header(header::ACCEPT, mime::APPLICATION_JSON.essence_str()) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.essence_str()) + .header(header::AUTHORIZATION, format!("token {token}"))) + } + + pub async fn workflow_run(&self, query: &RunQuery) -> Result { + if query.is_github() { + self.workflow_run_github(query).await + } else { + self.workflow_run_forgejo(query).await + } + } + + async fn workflow_run_forgejo(&self, query: &RunQuery) -> Result { + // Since the workflow needs to be fetched with a POST request, we need a CSRF token + let resp = self + .http + .get(format!("https://{}", query.host)) + .send() + .await? + .error_for_status()?; + let mut i_like_gitea = None; + let mut csrf = None; + for (k, v) in resp + .headers() + .get_all(header::SET_COOKIE) + .into_iter() + .filter_map(|v| v.to_str().ok()) + .filter_map(|v| v.split(';').next()) + .filter_map(|v| v.split_once('=')) + { + match k { + "i_like_gitea" => i_like_gitea = Some(v), + "_csrf" => csrf = Some(v), + _ => {} + } + } + let i_like_gitea = + i_like_gitea.ok_or(Error::Other("missing header: i_like_gitea".into()))?; + let csrf = csrf.ok_or(Error::Other("missing header: _csrf".into()))?; + + let resp = self + .http + .post(format!( + "https://{}/{}/{}/actions/runs/{}/jobs/0", + query.host, query.user, query.repo, query.run + )) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.essence_str()) + .header(header::COOKIE, format!("i_like_gitea={i_like_gitea}")) + .header("x-csrf-token", csrf) + .body(r#"{"logCursors":[{"step":0,"cursor":null,"expanded":true}]}"#) + .send() + .await? + .error_for_status()?; + let run: WorkflowRun = resp.json::().await?.try_into()?; + Ok(run) + } + + async fn workflow_run_github(&self, query: &RunQuery) -> Result { + let run = Self::send_api_req::(self.get_github(format!( + "https://api.github.com/repos/{}/{}/actions/runs/{}", + query.user, query.repo, query.run + ))) + .await?; + Ok(run.into()) + } + + pub async fn add_comment( + &self, + query: QueryRef<'_>, + issue_id: u64, + content: &str, + old_comment_id: Option, + recreate: bool, + ) -> Result { + let body = format!("{COMMENT_TAG_PATTERN}\n{content}"); + if query.is_github() { + self.add_comment_github(query, issue_id, &body, old_comment_id, recreate) + .await + } else { + self.add_comment_forgejo(query, issue_id, &body, old_comment_id, recreate) + .await + } + } + + async fn add_comment_forgejo( + &self, + query: QueryRef<'_>, + issue_id: u64, + body: &str, + old_comment_id: Option, + recreate: bool, + ) -> Result { + if let Some(old_comment_id) = old_comment_id { + let url = format!( + "https://{}/api/v1/repos/{}/{}/issues/comments/{}", + query.host, query.user, query.repo, old_comment_id + ); + if recreate { + Self::send_api_req_empty(self.req_forgejo(Method::DELETE, url)?).await?; + } else { + Self::send_api_req_empty( + self.req_forgejo(Method::PATCH, url)? + .json(&CommentBody { body }), + ) + .await?; + return Ok(old_comment_id); + } + } + + let new_c = Self::send_api_req::( + self.req_forgejo( + Method::POST, + format!( + "https://{}/api/v1/repos/{}/{}/issues/{}/comments", + query.host, query.user, query.repo, issue_id + ), + )? + .json(&CommentBody { body }), + ) + .await?; + Ok(new_c.id) + } + + async fn add_comment_github( + &self, + query: QueryRef<'_>, + issue_id: u64, + body: &str, + old_comment_id: Option, + recreate: bool, + ) -> Result { + if let Some(old_comment_id) = old_comment_id { + let url = format!( + "https://api.github.com/repos/{}/{}/issues/{}/comments/{}", + query.user, query.repo, issue_id, old_comment_id + ); + if recreate { + Self::send_api_req_empty(self.req_github(Method::DELETE, url)?).await?; + } else { + Self::send_api_req_empty( + self.req_github(Method::PATCH, url)? + .json(&CommentBody { body }), + ) + .await?; + return Ok(old_comment_id); + } + } + + let new_c = Self::send_api_req::( + self.req_github( + Method::POST, + format!( + "https://api.github.com/repos/{}/{}/issues/{}/comments", + query.user, query.repo, issue_id + ), + )? + .json(&CommentBody { body }), + ) + .await?; + Ok(new_c.id) + } + + pub async fn find_comment( + &self, + query: QueryRef<'_>, + issue_id: u64, + ) -> Result> { + let user_id = self.get_user_id(query).await?; + if query.is_github() { + self.find_comment_github(query, issue_id, user_id).await + } else { + self.find_comment_forgejo(query, issue_id, user_id).await + } + } + + async fn find_comment_forgejo( + &self, + query: QueryRef<'_>, + issue_id: u64, + user_id: u64, + ) -> Result> { + let comments = Self::send_api_req::>(self.get_forgejo(format!( + "https://{}/api/v1/repos/{}/{}/issues/{}/comments", + query.host, query.user, query.repo, issue_id + ))) + .await?; + + Ok(comments + .into_iter() + .find(|c| c.user.id == user_id && c.body.starts_with(COMMENT_TAG_PATTERN))) + } + + async fn find_comment_github( + &self, + query: QueryRef<'_>, + issue_id: u64, + user_id: u64, + ) -> Result> { + for page in 1..=5 { + let comments = Self::send_api_req::>(self.get_github(format!( + "https://api.github.com/repos/{}/{}/issues/{}/comments?page={}", + query.user, query.repo, issue_id, page + ))) + .await?; + if let Some(comment) = comments + .into_iter() + .find(|c| c.user.id == user_id && c.body.starts_with(COMMENT_TAG_PATTERN)) + { + return Ok(Some(comment)); + } + } + Ok(None) + } + + pub async fn get_pr(&self, query: QueryRef<'_>, pr_id: u64) -> Result { + let req = if query.is_github() { + self.get_github(format!( + "https://api.github.com/repos/{}/{}/pulls/{}", + query.user, query.repo, pr_id + )) + } else { + self.get_forgejo(format!( + "https://{}/api/v1/repos/{}/{}/pulls/{}", + query.host, query.user, query.repo, pr_id + )) + }; + Self::send_api_req(req).await + } + + async fn get_user_id(&self, query: QueryRef<'_>) -> Result { + self.user_ids + .get_or_insert_async(query.host, async { + let user = + if query.is_github() { + Self::send_api_req::( + self.req_github(Method::GET, "https://api.github.com/user")?, + ) + .await? + } else { + Self::send_api_req::(self.req_forgejo( + Method::GET, + format!("https://{}/api/v1/user", query.host), + )?) + .await? + }; + Ok::<_, Error>(user.id) + }) + .await + } } #[cfg(test)] mod tests { use std::collections::HashMap; - use crate::{config::Config, query::ArtifactQuery}; + use time::macros::datetime; + + use crate::{ + config::Config, + query::{ArtifactQuery, RunQuery}, + }; use super::ArtifactApi; @@ -321,4 +781,31 @@ mod tests { assert_eq!(res.id, 1440556464); assert_eq!(res.size, 334); } + + #[tokio::test] + #[ignore] + async fn workflow_run_forgejo() { + let query = + RunQuery::from_forge_url("https://codeberg.org/forgejo/forgejo/actions/runs/20471") + .unwrap(); + let api = ArtifactApi::new(Config::default()); + let res = api.workflow_run(&query).await.unwrap(); + assert_eq!(res.head_sha, "03581511024aca9b56bc6083565bdcebeacb9d05"); + assert!(res.from_pr); + assert_eq!(res.date_started, Some(datetime!(2024-06-21 9:13:23 UTC))); + } + + #[tokio::test] + #[ignore] + async fn workflow_run_github() { + let query = + RunQuery::from_forge_url("https://github.com/orhun/git-cliff/actions/runs/9588266559") + .unwrap(); + let api = ArtifactApi::new(Config::default()); + let res = api.workflow_run(&query).await.unwrap(); + dbg!(&res); + assert_eq!(res.head_sha, "0500cb2c5c5ec225e109584236940ee068be2372"); + assert!(res.from_pr); + assert_eq!(res.date_started, Some(datetime!(2024-06-21 9:13:23 UTC))); + } } diff --git a/src/cache.rs b/src/cache.rs index 737e514..f349558 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -166,10 +166,10 @@ impl Cache { let metadata = tokio::fs::metadata(&zip_path).await?; let modified = metadata .modified() - .map_err(|_| Error::Internal("no file modified time".into()))?; + .map_err(|_| Error::Other("no file modified time".into()))?; let accessed = metadata .accessed() - .map_err(|_| Error::Internal("no file accessed time".into()))?; + .map_err(|_| Error::Other("no file accessed time".into()))?; if modified != entry.last_modified { tracing::info!("cached file {zip_path:?} changed"); entry = Arc::new( @@ -182,7 +182,7 @@ impl Cache { let now = SystemTime::now(); if now .duration_since(accessed) - .map_err(|e| Error::Internal(e.to_string().into()))? + .map_err(|e| Error::Other(e.to_string().into()))? > Duration::from_secs(1800) { let file = std::fs::File::open(&zip_path)?; @@ -215,10 +215,10 @@ impl Cache { .metadata() .await? .accessed() - .map_err(|_| Error::Internal("no file accessed time".into()))?; + .map_err(|_| Error::Other("no file accessed time".into()))?; if now .duration_since(accessed) - .map_err(|e| Error::Internal(e.to_string().into()))? + .map_err(|e| Error::Other(e.to_string().into()))? > max_age { let path = entry.path(); @@ -289,7 +289,7 @@ impl CacheEntry { name, last_modified: meta .modified() - .map_err(|_| Error::Internal("no file modified time".into()))?, + .map_err(|_| Error::Other("no file modified time".into()))?, }) } diff --git a/src/config.rs b/src/config.rs index 0c524f3..cbc2e66 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,11 +5,12 @@ use std::{ sync::Arc, }; +use secrecy::Secret; use serde::Deserialize; use crate::{ error::{Error, Result}, - query::{ArtifactQuery, QueryFilterList}, + query::{Query, QueryFilterList}, }; #[derive(Clone)] @@ -48,7 +49,9 @@ pub struct ConfigData { /// GitHub API token for downloading GitHub artifacts /// /// Using a fine-grained token with public read permissions is recommended. - pub github_token: Option, + pub github_token: Option>, + /// Forgejo/Gitea API tokens by host + pub forgejo_tokens: HashMap>, /// Number of artifact indexes to keep in memory pub mem_cache_size: usize, /// Get the client IP address from a HTTP request header @@ -61,6 +64,8 @@ pub struct ConfigData { pub real_ip_header: Option, /// Limit the amount of downloaded artifacts per IP address and minute pub limit_artifacts_per_min: Option, + /// Limit the amount of PR comment API requests per IP address and minute + pub limit_pr_comments_per_min: Option, /// List of sites/users/repos that can NOT be accessed pub repo_blacklist: QueryFilterList, /// List of sites/users/repos that can ONLY be accessed @@ -89,9 +94,11 @@ impl Default for ConfigData { max_age_h: NonZeroU32::new(12).unwrap(), zip_timeout_ms: Some(NonZeroU32::new(1000).unwrap()), github_token: None, + forgejo_tokens: HashMap::new(), mem_cache_size: 50, real_ip_header: None, limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()), + limit_pr_comments_per_min: Some(NonZeroU32::new(5).unwrap()), repo_blacklist: QueryFilterList::default(), repo_whitelist: QueryFilterList::default(), suggested_sites: vec![ @@ -124,7 +131,7 @@ impl ConfigData { impl Config { pub fn new() -> Result { let data = - envy::from_env::().map_err(|e| Error::Internal(e.to_string().into()))?; + envy::from_env::().map_err(|e| Error::Other(e.to_string().into()))?; Self::from_data(data) } @@ -173,7 +180,7 @@ impl Config { .unwrap_or("codeberg.org") } - pub fn check_filterlist(&self, query: &ArtifactQuery) -> Result<()> { + pub fn check_filterlist(&self, query: &Q) -> Result<()> { if !self.i.data.repo_blacklist.passes(query, true) { Err(Error::Forbidden("repository is blacklisted".into())) } else if !self.i.data.repo_whitelist.passes(query, false) { diff --git a/src/error.rs b/src/error.rs index 8d3097f..0c6d30a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,8 +20,8 @@ pub enum Error { Io(#[from] std::io::Error), #[error("Zip: {0}")] Zip(#[from] async_zip::error::ZipError), - #[error("Internal error: {0}")] - Internal(Cow<'static, str>), + #[error("Error: {0}")] + Other(Cow<'static, str>), #[error("Invalid request: {0}")] BadRequest(Cow<'static, str>), @@ -58,13 +58,13 @@ impl From for Error { impl From for Error { fn from(value: std::num::TryFromIntError) -> Self { - Self::Internal(value.to_string().into()) + Self::Other(value.to_string().into()) } } impl From for Error { fn from(value: url::ParseError) -> Self { - Self::Internal(value.to_string().into()) + Self::Other(value.to_string().into()) } } diff --git a/src/query.rs b/src/query.rs index ddf206c..8060f11 100644 --- a/src/query.rs +++ b/src/query.rs @@ -148,7 +148,11 @@ impl ArtifactQuery { } impl RunQuery { - pub fn from_forge_url(url: &str, aliases: &HashMap) -> Result { + pub fn from_forge_url(url: &str) -> Result { + Self::from_forge_url_alias(url, &HashMap::new()) + } + + pub fn from_forge_url_alias(url: &str, aliases: &HashMap) -> Result { let (host, mut path_segs) = util::parse_url(url)?; let user = path_segs @@ -331,12 +335,12 @@ impl FromStr for QueryFilter { if let Some(user) = &user { if !RE_REPO_NAME.is_match(user) { - return Err(Error::Internal("invalid username".into())); + return Err(Error::Other("invalid username".into())); } } if let Some(repo) = &repo { if !RE_REPO_NAME.is_match(repo) { - return Err(Error::Internal("invalid repository name".into())); + return Err(Error::Other("invalid repository name".into())); } } @@ -370,7 +374,7 @@ impl FromStr for QueryFilterList { } impl QueryFilterList { - pub fn passes(&self, query: &ArtifactQuery, blacklist: bool) -> bool { + pub fn passes(&self, query: &Q, blacklist: bool) -> bool { if self.0.is_empty() { true } else { diff --git a/src/snapshots/artifactview__app__tests__pr_comment_1.snap b/src/snapshots/artifactview__app__tests__pr_comment_1.snap new file mode 100644 index 0000000..d86a280 --- /dev/null +++ b/src/snapshots/artifactview__app__tests__pr_comment_1.snap @@ -0,0 +1,9 @@ +--- +source: src/app.rs +expression: res +--- +### Latest build artifacts [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] + +🏠 Hello World ;-)
+Test
+ diff --git a/src/snapshots/artifactview__app__tests__pr_comment_2.snap b/src/snapshots/artifactview__app__tests__pr_comment_2.snap new file mode 100644 index 0000000..8d54b0d --- /dev/null +++ b/src/snapshots/artifactview__app__tests__pr_comment_2.snap @@ -0,0 +1,14 @@ +--- +source: src/app.rs +expression: res +--- +### Latest build artifacts [[25eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/25eed48a8382513147a949117ef4aa659989d397)] + +🏠 Hello World ;-)
+Test
+
+Previous builds + +- [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] Hello, Test (15.06.2024 01:30:00 UTC) + +
diff --git a/src/snapshots/artifactview__app__tests__pr_comment_3.snap b/src/snapshots/artifactview__app__tests__pr_comment_3.snap new file mode 100644 index 0000000..9cfbd08 --- /dev/null +++ b/src/snapshots/artifactview__app__tests__pr_comment_3.snap @@ -0,0 +1,15 @@ +--- +source: src/app.rs +expression: res +--- +### Latest build artifacts [[35eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/35eed48a8382513147a949117ef4aa659989d397)] + +🏠 Hello World ;-)
+Test
+
+Previous builds + +- [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] Hello, Test (15.06.2024 01:30:00 UTC) +- [[25eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/25eed48a8382513147a949117ef4aa659989d397)] Hello, Test (15.06.2024 02:30:00 UTC) + +
diff --git a/src/util.rs b/src/util.rs index 5ce1238..bdd198c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -194,7 +194,7 @@ pub fn get_ip_address(request: &Request, real_ip_header: Option<&str>) -> Result let socket_addr = request .extensions() .get::>() - .ok_or(Error::Internal("could get request ip address".into()))? + .ok_or(Error::Other("could get request ip address".into()))? .0; Ok(socket_addr.ip()) } @@ -263,6 +263,15 @@ pub struct ErrorJson { msg: String, } +impl ErrorJson { + pub fn ok>(msg: S) -> Self { + Self { + status: 200, + msg: msg.into(), + } + } +} + impl From for ErrorJson { fn from(value: Error) -> Self { Self { @@ -284,6 +293,15 @@ impl IntoResponse for ErrorJson { } } +pub fn extract_delim<'a>(s: &'a str, start: &str, end: &str) -> Option<&'a str> { + if let Some(np) = s.find(start) { + if let Some(np_end) = s[np + start.len()..].find(end) { + return Some(s[np + start.len()..np + start.len() + np_end].trim()); + } + } + None +} + #[cfg(test)] pub(crate) mod tests { use std::path::{Path, PathBuf}; diff --git a/tests/testfiles/giteaWorkflowRun.json b/tests/testfiles/giteaWorkflowRun.json new file mode 100644 index 0000000..023f85b --- /dev/null +++ b/tests/testfiles/giteaWorkflowRun.json @@ -0,0 +1,320 @@ +{ + "state": { + "run": { + "link": "/ThetaDev/test-actions/actions/runs/92", + "title": "Update README.md", + "status": "success", + "canCancel": false, + "canApprove": false, + "canRerun": true, + "canDeleteArtifact": true, + "done": true, + "jobs": [ + { + "id": 377, + "name": "Test", + "status": "success", + "canRerun": true, + "duration": "2s" + } + ], + "commit": { + "localeCommit": "Commit", + "localePushedBy": "pushed by", + "localeWorkflow": "Workflow", + "shortSHA": "6185409d45", + "link": "/ThetaDev/test-actions/commit/6185409d457e0a7833ee122811b138a950273229", + "pusher": { "displayName": "ThetaDev", "link": "/ThetaDev" }, + "branch": { "name": "#3", "link": "/ThetaDev/test-actions/pulls/3" } + } + }, + "currentJob": { + "title": "Test", + "detail": "Success", + "steps": [ + { + "summary": "Set up job", + "duration": "1s", + "status": "success" + }, + { "summary": "Test", "duration": "0s", "status": "success" }, + { + "summary": "Comment PR", + "duration": "1s", + "status": "success" + }, + { + "summary": "Complete job", + "duration": "0s", + "status": "success" + } + ] + } + }, + "logs": { + "stepsLog": [ + { + "step": 0, + "cursor": 51, + "lines": [ + { + "index": 1, + "message": "ocloud(version:v3.4.1) received task 431 of job 377, be triggered by event: pull_request", + "timestamp": 1718902104.1911685 + }, + { + "index": 2, + "message": "workflow prepared", + "timestamp": 1718902104.1916893 + }, + { + "index": 3, + "message": "evaluating expression 'success()'", + "timestamp": 1718902104.1919434 + }, + { + "index": 4, + "message": "expression 'success()' evaluated to 'true'", + "timestamp": 1718902104.1920443 + }, + { + "index": 5, + "message": "🚀 Start image=thetadev256/cimaster:latest", + "timestamp": 1718902104.1920674 + }, + { + "index": 6, + "message": " 🐳 docker pull image=thetadev256/cimaster:latest platform= username= forcePull=true", + "timestamp": 1718902104.203115 + }, + { + "index": 7, + "message": " 🐳 docker pull thetadev256/cimaster:latest", + "timestamp": 1718902104.2031355 + }, + { + "index": 8, + "message": "pulling image 'docker.io/thetadev256/cimaster:latest' ()", + "timestamp": 1718902104.2031558 + }, + { + "index": 9, + "message": "Pulling from thetadev256/cimaster :: latest", + "timestamp": 1718902105.179988 + }, + { + "index": 10, + "message": "Digest: sha256:260659581e2900354877f31d5fec14db1c40999ad085a90a1a27c44b9cab8c48 :: ", + "timestamp": 1718902105.1935806 + }, + { + "index": 11, + "message": "Status: Image is up to date for thetadev256/cimaster:latest :: ", + "timestamp": 1718902105.1936345 + }, + { + "index": 12, + "message": "[{host 26303131f98bfa59ece2ca2dc1742040c8d125e22f1c07de603bd235bccf1b84 2024-04-02 20:48:57.065188715 +0000 UTC local host false {default map[] []} false false false {} false map[] map[] map[] [] map[]} {GITEA-ACTIONS-TASK-375_WORKFLOW-Build-and-push-cimaster-image_JOB-build-build-network 59ca72b83dbd990bda7af5e760fdb0e31010b855ca7e15df1a471fda8908aa47 2024-05-30 20:56:22.698163954 +0000 UTC local bridge false {default map[] [{172.24.0.0/16 172.24.0.1 map[]}]} false false false {} false map[] map[] map[] [] map[]} {none 627a84562dca9a81bd4a1fe570919035f1d608382d2b66b6b8559756d7aa2a6c 2024-04-02 20:48:57.050063369 +0000 UTC local null false {default map[] []} false false false {} false map[] map[] map[] [] map[]} {bridge 9b05952ef8d41f6a92bbc3c7f85c9fd5602e941b9e8e3cbcf84716de27d77aa9 2024-06-20 09:15:48.433095842 +0000 UTC local bridge false {default map[] [{172.17.0.0/16 172.17.0.1 map[]}]} false false false {} false map[] map[com.docker.network.bridge.default_bridge:true com.docker.network.bridge.enable_icc:true com.docker.network.bridge.enable_ip_masquerade:true com.docker.network.bridge.host_binding_ipv4:0.0.0.0 com.docker.network.bridge.name:docker0 com.docker.network.driver.mtu:1500] map[] [] map[]}]", + "timestamp": 1718902105.203977 + }, + { + "index": 13, + "message": " 🐳 docker create image=thetadev256/cimaster:latest platform= entrypoint=[\"tail\" \"-f\" \"/dev/null\"] cmd=[] network=\"GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test-Test-network\"", + "timestamp": 1718902105.2669988 + }, + { + "index": 14, + "message": "Common container.Config ==\u003e \u0026{Hostname: Domainname: User: AttachStdin:false AttachStdout:false AttachStderr:false ExposedPorts:map[] Tty:false OpenStdin:false StdinOnce:false Env:[RUNNER_TOOL_CACHE=/opt/hostedtoolcache RUNNER_OS=Linux RUNNER_ARCH=ARM64 RUNNER_TEMP=/tmp LANG=C.UTF-8] Cmd:[] Healthcheck:\u003cnil\u003e ArgsEscaped:false Image:thetadev256/cimaster:latest Volumes:map[] WorkingDir:/workspace/ThetaDev/test-actions Entrypoint:[] NetworkDisabled:false MacAddress: OnBuild:[] Labels:map[] StopSignal: StopTimeout:\u003cnil\u003e Shell:[]}", + "timestamp": 1718902105.2674763 + }, + { + "index": 15, + "message": "Common container.HostConfig ==\u003e \u0026{Binds:[] ContainerIDFile: LogConfig:{Type: Config:map[]} NetworkMode:GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test-Test-network PortBindings:map[] RestartPolicy:{Name: MaximumRetryCount:0} AutoRemove:true VolumeDriver: VolumesFrom:[] ConsoleSize:[0 0] Annotations:map[] CapAdd:[] CapDrop:[] CgroupnsMode: DNS:[] DNSOptions:[] DNSSearch:[] ExtraHosts:[] GroupAdd:[] IpcMode: Cgroup: Links:[] OomScoreAdj:0 PidMode: Privileged:false PublishAllPorts:false ReadonlyRootfs:false SecurityOpt:[] StorageOpt:map[] Tmpfs:map[] UTSMode: UsernsMode: ShmSize:0 Sysctls:map[] Runtime: Isolation: Resources:{CPUShares:0 Memory:0 NanoCPUs:0 CgroupParent: BlkioWeight:0 BlkioWeightDevice:[] BlkioDeviceReadBps:[] BlkioDeviceWriteBps:[] BlkioDeviceReadIOps:[] BlkioDeviceWriteIOps:[] CPUPeriod:0 CPUQuota:0 CPURealtimePeriod:0 CPURealtimeRuntime:0 CpusetCpus: CpusetMems: Devices:[] DeviceCgroupRules:[] DeviceRequests:[] KernelMemory:0 KernelMemoryTCP:0 MemoryReservation:0 MemorySwap:0 MemorySwappiness:\u003cnil\u003e OomKillDisable:\u003cnil\u003e PidsLimit:\u003cnil\u003e Ulimits:[] CPUCount:0 CPUPercent:0 IOMaximumIOps:0 IOMaximumBandwidth:0} Mounts:[{Type:volume Source:GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test-env Target:/var/run/act ReadOnly:false Consistency: BindOptions:\u003cnil\u003e VolumeOptions:\u003cnil\u003e TmpfsOptions:\u003cnil\u003e ClusterOptions:\u003cnil\u003e} {Type:volume Source:GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test Target:/workspace/ThetaDev/test-actions ReadOnly:false Consistency: BindOptions:\u003cnil\u003e VolumeOptions:\u003cnil\u003e TmpfsOptions:\u003cnil\u003e ClusterOptions:\u003cnil\u003e} {Type:volume Source:act-toolcache Target:/opt/hostedtoolcache ReadOnly:false Consistency: BindOptions:\u003cnil\u003e VolumeOptions:\u003cnil\u003e TmpfsOptions:\u003cnil\u003e ClusterOptions:\u003cnil\u003e}] MaskedPaths:[] ReadonlyPaths:[] Init:\u003cnil\u003e}", + "timestamp": 1718902105.2731254 + }, + { + "index": 16, + "message": "input.NetworkAliases ==\u003e [Test]", + "timestamp": 1718902105.2733588 + }, + { + "index": 17, + "message": "Created container name=GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test id=953c4349622d86e68aa9b0d25b341f0ff4b36d87d5ffbae957d9efd72c3f6d64 from image thetadev256/cimaster:latest (platform: )", + "timestamp": 1718902105.3252785 + }, + { + "index": 18, + "message": "ENV ==\u003e [RUNNER_TOOL_CACHE=/opt/hostedtoolcache RUNNER_OS=Linux RUNNER_ARCH=ARM64 RUNNER_TEMP=/tmp LANG=C.UTF-8]", + "timestamp": 1718902105.3253257 + }, + { + "index": 19, + "message": " 🐳 docker run image=thetadev256/cimaster:latest platform= entrypoint=[\"tail\" \"-f\" \"/dev/null\"] cmd=[] network=\"GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test-Test-network\"", + "timestamp": 1718902105.3253412 + }, + { + "index": 20, + "message": "Starting container: 953c4349622d86e68aa9b0d25b341f0ff4b36d87d5ffbae957d9efd72c3f6d64", + "timestamp": 1718902105.3253546 + }, + { + "index": 21, + "message": "Started container: 953c4349622d86e68aa9b0d25b341f0ff4b36d87d5ffbae957d9efd72c3f6d64", + "timestamp": 1718902105.5463858 + }, + { + "index": 22, + "message": " 🐳 docker exec cmd=[chown -R 1000:1000 /workspace/ThetaDev/test-actions] user=0 workdir=", + "timestamp": 1718902105.6031785 + }, + { + "index": 23, + "message": "Exec command '[chown -R 1000:1000 /workspace/ThetaDev/test-actions]'", + "timestamp": 1718902105.6032245 + }, + { + "index": 24, + "message": "Working directory '/workspace/ThetaDev/test-actions'", + "timestamp": 1718902105.6032348 + }, + { + "index": 25, + "message": "Writing entry to tarball workflow/event.json len:8795", + "timestamp": 1718902105.6331673 + }, + { + "index": 26, + "message": "Writing entry to tarball workflow/envs.txt len:0", + "timestamp": 1718902105.6332214 + }, + { + "index": 27, + "message": "Extracting content to '/var/run/act/'", + "timestamp": 1718902105.6332443 + }, + { + "index": 28, + "message": " ☁ git clone 'https://code.thetadev.de/actions/comment-pull-request' # ref=v1", + "timestamp": 1718902105.6492677 + }, + { + "index": 29, + "message": " cloning https://code.thetadev.de/actions/comment-pull-request to /data/.cache/act/https---code.thetadev.de-actions-comment-pull-request@v1", + "timestamp": 1718902105.6493008 + }, + { + "index": 30, + "message": "Cloned https://code.thetadev.de/actions/comment-pull-request to /data/.cache/act/https---code.thetadev.de-actions-comment-pull-request@v1", + "timestamp": 1718902105.6817598 + }, + { + "index": 31, + "message": "Checked out v1", + "timestamp": 1718902105.7090926 + }, + { + "index": 32, + "message": "Read action \u0026{Comment Pull Request Comments a pull request with the provided message map[GITHUB_TOKEN:{Github token of the repository (automatically created by Github) false ${{ github.token }}} comment_tag:{A tag on your comment that will be used to identify a comment in case of replacement. false } create_if_not_exists:{Whether a comment should be created even if comment_tag is not found. false true} filePath:{Path of the file that should be commented false } message:{Message that should be printed in the pull request false } pr_number:{Manual pull request number false } reactions:{You can set some reactions on your comments through the `reactions` input. false } recreate:{Delete and recreate the comment instead of updating it false false}] map[] {node20 map[] act/index.js always() always() [] []} {blue message-circle}} from 'Unknown'", + "timestamp": 1718902105.709367 + }, + { + "index": 33, + "message": "setupEnv =\u003e map[ACT:true ACTIONS_CACHE_URL:http://192.168.96.3:44491/ ACTIONS_RESULTS_URL:https://code.thetadev.de ACTIONS_RUNTIME_TOKEN:*** ACTIONS_RUNTIME_URL:https://code.thetadev.de/api/actions_pipeline/ CI:true GITEA_ACTIONS:true GITEA_ACTIONS_RUNNER_VERSION:v3.4.1 GITHUB_ACTION:0 GITHUB_ACTIONS:true GITHUB_ACTION_PATH: GITHUB_ACTION_REF: GITHUB_ACTION_REPOSITORY: GITHUB_ACTOR:ThetaDev GITHUB_API_URL:https://code.thetadev.de/api/v1 GITHUB_BASE_REF:main GITHUB_EVENT_NAME:pull_request GITHUB_EVENT_PATH:/var/run/act/workflow/event.json GITHUB_GRAPHQL_URL: GITHUB_HEAD_REF:thetadev-patch-2 GITHUB_JOB:Test GITHUB_REF:refs/pull/3/head GITHUB_REF_NAME:3 GITHUB_REF_TYPE: GITHUB_REPOSITORY:ThetaDev/test-actions GITHUB_REPOSITORY_OWNER:ThetaDev GITHUB_RETENTION_DAYS: GITHUB_RUN_ID:292 GITHUB_RUN_NUMBER:92 GITHUB_SERVER_URL:https://code.thetadev.de GITHUB_SHA:6185409d457e0a7833ee122811b138a950273229 GITHUB_TOKEN:*** GITHUB_WORKFLOW:Rust test GITHUB_WORKSPACE:/workspace/ThetaDev/test-actions ImageOS:cimasterlatest JOB_CONTAINER_NAME:GITEA-ACTIONS-TASK-431_WORKFLOW-Rust-test_JOB-Test RUNNER_PERFLOG:/dev/null RUNNER_TRACKING_ID:]", + "timestamp": 1718902105.7244232 + }, + { + "index": 34, + "message": "evaluating expression ''", + "timestamp": 1718902105.7316134 + }, + { + "index": 35, + "message": "expression '' evaluated to 'true'", + "timestamp": 1718902105.7316737 + }, + { + "index": 36, + "message": "⭐ Run Main Test", + "timestamp": 1718902105.7316883 + }, + { + "index": 37, + "message": "Writing entry to tarball workflow/outputcmd.txt len:0", + "timestamp": 1718902105.7317095 + }, + { + "index": 38, + "message": "Writing entry to tarball workflow/statecmd.txt len:0", + "timestamp": 1718902105.7317333 + }, + { + "index": 39, + "message": "Writing entry to tarball workflow/pathcmd.txt len:0", + "timestamp": 1718902105.7317476 + }, + { + "index": 40, + "message": "Writing entry to tarball workflow/envs.txt len:0", + "timestamp": 1718902105.7317617 + }, + { + "index": 41, + "message": "Writing entry to tarball workflow/SUMMARY.md len:0", + "timestamp": 1718902105.7317736 + }, + { + "index": 42, + "message": "Extracting content to '/var/run/act'", + "timestamp": 1718902105.731786 + }, + { + "index": 43, + "message": "expression 'echo \"${{ secrets.FORGEJO_CI_TOKEN }}\"\\n' rewritten to 'format('echo \"{0}\"\\n', secrets.FORGEJO_CI_TOKEN)'", + "timestamp": 1718902105.754891 + }, + { + "index": 44, + "message": "evaluating expression 'format('echo \"{0}\"\\n', secrets.FORGEJO_CI_TOKEN)'", + "timestamp": 1718902105.7549253 + }, + { + "index": 45, + "message": "expression 'format('echo \"{0}\"\\n', secrets.FORGEJO_CI_TOKEN)' evaluated to '%!t(string=echo \"***\"\\n)'", + "timestamp": 1718902105.7549586 + }, + { + "index": 46, + "message": "Wrote command \\n\\necho \"***\"\\n\\n\\n to 'workflow/0'", + "timestamp": 1718902105.754978 + }, + { + "index": 47, + "message": "Writing entry to tarball workflow/0 len:50", + "timestamp": 1718902105.755002 + }, + { + "index": 48, + "message": "Extracting content to '/var/run/act'", + "timestamp": 1718902105.755024 + }, + { + "index": 49, + "message": " 🐳 docker exec cmd=[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/0] user= workdir=", + "timestamp": 1718902105.7571557 + }, + { + "index": 50, + "message": "Exec command '[bash --noprofile --norc -e -o pipefail /var/run/act/workflow/0]'", + "timestamp": 1718902105.7571852 + }, + { + "index": 51, + "message": "Working directory '/workspace/ThetaDev/test-actions'", + "timestamp": 1718902105.7572272 + } + ], + "started": 1718902104 + } + ] + } +} diff --git a/tests/testfiles/githubWorkflowRun.json b/tests/testfiles/githubWorkflowRun.json new file mode 100644 index 0000000..fbdb46e --- /dev/null +++ b/tests/testfiles/githubWorkflowRun.json @@ -0,0 +1,220 @@ +{ + "id": 9598566319, + "name": "db-tests", + "node_id": "WFR_kwLOBFIx288AAAACPB5_rw", + "head_branch": "fix-ui-tab", + "head_sha": "7ae95457156ea964402747ae263d5a2a7de48883", + "path": ".github/workflows/pull-db-tests.yml", + "display_title": "WIP: Fix tab performance", + "run_number": 20434, + "event": "pull_request", + "status": "completed", + "conclusion": "success", + "workflow_id": 56971384, + "check_suite_id": 25125296548, + "check_suite_node_id": "CS_kwDOBFIx288AAAAF2ZWZpA", + "url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319", + "html_url": "https://github.com/go-gitea/gitea/actions/runs/9598566319", + "pull_requests": [], + "created_at": "2024-06-20T13:41:06Z", + "updated_at": "2024-06-20T14:10:02Z", + "actor": { + "login": "wxiaoguang", + "id": 2114189, + "node_id": "MDQ6VXNlcjIxMTQxODk=", + "avatar_url": "https://avatars.githubusercontent.com/u/2114189?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/wxiaoguang", + "html_url": "https://github.com/wxiaoguang", + "followers_url": "https://api.github.com/users/wxiaoguang/followers", + "following_url": "https://api.github.com/users/wxiaoguang/following{/other_user}", + "gists_url": "https://api.github.com/users/wxiaoguang/gists{/gist_id}", + "starred_url": "https://api.github.com/users/wxiaoguang/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wxiaoguang/subscriptions", + "organizations_url": "https://api.github.com/users/wxiaoguang/orgs", + "repos_url": "https://api.github.com/users/wxiaoguang/repos", + "events_url": "https://api.github.com/users/wxiaoguang/events{/privacy}", + "received_events_url": "https://api.github.com/users/wxiaoguang/received_events", + "type": "User", + "site_admin": false + }, + "run_attempt": 1, + "referenced_workflows": [ + { + "path": "go-gitea/gitea/.github/workflows/files-changed.yml@d8d6749d313098583fc1d527ce8a4aafb81ca12d", + "sha": "d8d6749d313098583fc1d527ce8a4aafb81ca12d", + "ref": "refs/pull/31437/merge" + } + ], + "run_started_at": "2024-06-20T13:41:06Z", + "triggering_actor": { + "login": "wxiaoguang", + "id": 2114189, + "node_id": "MDQ6VXNlcjIxMTQxODk=", + "avatar_url": "https://avatars.githubusercontent.com/u/2114189?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/wxiaoguang", + "html_url": "https://github.com/wxiaoguang", + "followers_url": "https://api.github.com/users/wxiaoguang/followers", + "following_url": "https://api.github.com/users/wxiaoguang/following{/other_user}", + "gists_url": "https://api.github.com/users/wxiaoguang/gists{/gist_id}", + "starred_url": "https://api.github.com/users/wxiaoguang/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wxiaoguang/subscriptions", + "organizations_url": "https://api.github.com/users/wxiaoguang/orgs", + "repos_url": "https://api.github.com/users/wxiaoguang/repos", + "events_url": "https://api.github.com/users/wxiaoguang/events{/privacy}", + "received_events_url": "https://api.github.com/users/wxiaoguang/received_events", + "type": "User", + "site_admin": false + }, + "jobs_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/jobs", + "logs_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/logs", + "check_suite_url": "https://api.github.com/repos/go-gitea/gitea/check-suites/25125296548", + "artifacts_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/artifacts", + "cancel_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/cancel", + "rerun_url": "https://api.github.com/repos/go-gitea/gitea/actions/runs/9598566319/rerun", + "previous_attempt_url": null, + "workflow_url": "https://api.github.com/repos/go-gitea/gitea/actions/workflows/56971384", + "head_commit": { + "id": "7ae95457156ea964402747ae263d5a2a7de48883", + "tree_id": "edb45bf6711cdcff1ee0347e330a0bd5b89996ec", + "message": "fix", + "timestamp": "2024-06-20T13:40:55Z", + "author": { "name": "wxiaoguang", "email": "wxiaoguang@gmail.com" }, + "committer": { "name": "wxiaoguang", "email": "wxiaoguang@gmail.com" } + }, + "repository": { + "id": 72495579, + "node_id": "MDEwOlJlcG9zaXRvcnk3MjQ5NTU3OQ==", + "name": "gitea", + "full_name": "go-gitea/gitea", + "private": false, + "owner": { + "login": "go-gitea", + "id": 12724356, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjEyNzI0MzU2", + "avatar_url": "https://avatars.githubusercontent.com/u/12724356?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/go-gitea", + "html_url": "https://github.com/go-gitea", + "followers_url": "https://api.github.com/users/go-gitea/followers", + "following_url": "https://api.github.com/users/go-gitea/following{/other_user}", + "gists_url": "https://api.github.com/users/go-gitea/gists{/gist_id}", + "starred_url": "https://api.github.com/users/go-gitea/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/go-gitea/subscriptions", + "organizations_url": "https://api.github.com/users/go-gitea/orgs", + "repos_url": "https://api.github.com/users/go-gitea/repos", + "events_url": "https://api.github.com/users/go-gitea/events{/privacy}", + "received_events_url": "https://api.github.com/users/go-gitea/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/go-gitea/gitea", + "description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD", + "fork": false, + "url": "https://api.github.com/repos/go-gitea/gitea", + "forks_url": "https://api.github.com/repos/go-gitea/gitea/forks", + "keys_url": "https://api.github.com/repos/go-gitea/gitea/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/go-gitea/gitea/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/go-gitea/gitea/teams", + "hooks_url": "https://api.github.com/repos/go-gitea/gitea/hooks", + "issue_events_url": "https://api.github.com/repos/go-gitea/gitea/issues/events{/number}", + "events_url": "https://api.github.com/repos/go-gitea/gitea/events", + "assignees_url": "https://api.github.com/repos/go-gitea/gitea/assignees{/user}", + "branches_url": "https://api.github.com/repos/go-gitea/gitea/branches{/branch}", + "tags_url": "https://api.github.com/repos/go-gitea/gitea/tags", + "blobs_url": "https://api.github.com/repos/go-gitea/gitea/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/go-gitea/gitea/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/go-gitea/gitea/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/go-gitea/gitea/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/go-gitea/gitea/statuses/{sha}", + "languages_url": "https://api.github.com/repos/go-gitea/gitea/languages", + "stargazers_url": "https://api.github.com/repos/go-gitea/gitea/stargazers", + "contributors_url": "https://api.github.com/repos/go-gitea/gitea/contributors", + "subscribers_url": "https://api.github.com/repos/go-gitea/gitea/subscribers", + "subscription_url": "https://api.github.com/repos/go-gitea/gitea/subscription", + "commits_url": "https://api.github.com/repos/go-gitea/gitea/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/go-gitea/gitea/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/go-gitea/gitea/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/go-gitea/gitea/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/go-gitea/gitea/contents/{+path}", + "compare_url": "https://api.github.com/repos/go-gitea/gitea/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/go-gitea/gitea/merges", + "archive_url": "https://api.github.com/repos/go-gitea/gitea/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/go-gitea/gitea/downloads", + "issues_url": "https://api.github.com/repos/go-gitea/gitea/issues{/number}", + "pulls_url": "https://api.github.com/repos/go-gitea/gitea/pulls{/number}", + "milestones_url": "https://api.github.com/repos/go-gitea/gitea/milestones{/number}", + "notifications_url": "https://api.github.com/repos/go-gitea/gitea/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/go-gitea/gitea/labels{/name}", + "releases_url": "https://api.github.com/repos/go-gitea/gitea/releases{/id}", + "deployments_url": "https://api.github.com/repos/go-gitea/gitea/deployments" + }, + "head_repository": { + "id": 398521154, + "node_id": "MDEwOlJlcG9zaXRvcnkzOTg1MjExNTQ=", + "name": "gitea", + "full_name": "wxiaoguang/gitea", + "private": false, + "owner": { + "login": "wxiaoguang", + "id": 2114189, + "node_id": "MDQ6VXNlcjIxMTQxODk=", + "avatar_url": "https://avatars.githubusercontent.com/u/2114189?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/wxiaoguang", + "html_url": "https://github.com/wxiaoguang", + "followers_url": "https://api.github.com/users/wxiaoguang/followers", + "following_url": "https://api.github.com/users/wxiaoguang/following{/other_user}", + "gists_url": "https://api.github.com/users/wxiaoguang/gists{/gist_id}", + "starred_url": "https://api.github.com/users/wxiaoguang/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wxiaoguang/subscriptions", + "organizations_url": "https://api.github.com/users/wxiaoguang/orgs", + "repos_url": "https://api.github.com/users/wxiaoguang/repos", + "events_url": "https://api.github.com/users/wxiaoguang/events{/privacy}", + "received_events_url": "https://api.github.com/users/wxiaoguang/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/wxiaoguang/gitea", + "description": "Git with a cup of tea, painless self-hosted git service", + "fork": true, + "url": "https://api.github.com/repos/wxiaoguang/gitea", + "forks_url": "https://api.github.com/repos/wxiaoguang/gitea/forks", + "keys_url": "https://api.github.com/repos/wxiaoguang/gitea/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/wxiaoguang/gitea/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/wxiaoguang/gitea/teams", + "hooks_url": "https://api.github.com/repos/wxiaoguang/gitea/hooks", + "issue_events_url": "https://api.github.com/repos/wxiaoguang/gitea/issues/events{/number}", + "events_url": "https://api.github.com/repos/wxiaoguang/gitea/events", + "assignees_url": "https://api.github.com/repos/wxiaoguang/gitea/assignees{/user}", + "branches_url": "https://api.github.com/repos/wxiaoguang/gitea/branches{/branch}", + "tags_url": "https://api.github.com/repos/wxiaoguang/gitea/tags", + "blobs_url": "https://api.github.com/repos/wxiaoguang/gitea/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/wxiaoguang/gitea/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/wxiaoguang/gitea/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/wxiaoguang/gitea/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/wxiaoguang/gitea/statuses/{sha}", + "languages_url": "https://api.github.com/repos/wxiaoguang/gitea/languages", + "stargazers_url": "https://api.github.com/repos/wxiaoguang/gitea/stargazers", + "contributors_url": "https://api.github.com/repos/wxiaoguang/gitea/contributors", + "subscribers_url": "https://api.github.com/repos/wxiaoguang/gitea/subscribers", + "subscription_url": "https://api.github.com/repos/wxiaoguang/gitea/subscription", + "commits_url": "https://api.github.com/repos/wxiaoguang/gitea/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/wxiaoguang/gitea/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/wxiaoguang/gitea/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/wxiaoguang/gitea/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/wxiaoguang/gitea/contents/{+path}", + "compare_url": "https://api.github.com/repos/wxiaoguang/gitea/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/wxiaoguang/gitea/merges", + "archive_url": "https://api.github.com/repos/wxiaoguang/gitea/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/wxiaoguang/gitea/downloads", + "issues_url": "https://api.github.com/repos/wxiaoguang/gitea/issues{/number}", + "pulls_url": "https://api.github.com/repos/wxiaoguang/gitea/pulls{/number}", + "milestones_url": "https://api.github.com/repos/wxiaoguang/gitea/milestones{/number}", + "notifications_url": "https://api.github.com/repos/wxiaoguang/gitea/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/wxiaoguang/gitea/labels{/name}", + "releases_url": "https://api.github.com/repos/wxiaoguang/gitea/releases{/id}", + "deployments_url": "https://api.github.com/repos/wxiaoguang/gitea/deployments" + } +} From 23b81014266728e3db1cb200a5cf46a212baed72 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 22 Jun 2024 05:17:18 +0200 Subject: [PATCH 2/3] feat: add url parameter to /artifacts API --- Cargo.toml | 1 + src/app.rs | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b4cbbc2..fdd21b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ axum = { version = "0.7.5", default-features = false, features = [ "http1", "http2", "json", + "query", "tokio", "tracing", ] } diff --git a/src/app.rs b/src/app.rs index cffecce..243a207 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use std::{ use async_zip::tokio::read::ZipEntryReader; use axum::{ body::Body, - extract::{Host, Request, State}, + extract::{Host, Query as XQuery, Request, State}, http::{Response, Uri}, response::{IntoResponse, Redirect}, routing::{any, get, post}, @@ -78,6 +78,11 @@ struct FileQparams { viewer: Option, } +#[derive(Deserialize)] +struct UrlQuery { + url: Option, +} + #[derive(Deserialize)] struct PrCommentReq { url: String, @@ -591,11 +596,18 @@ impl App { async fn get_artifacts( State(state): State, Host(host): Host, + url_query: XQuery, ) -> Result, ErrorJson> { - let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; - let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?; + let query = match &url_query.url { + Some(url) => RunQuery::from_forge_url(url)?, + None => { + let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?; + ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?.into() + } + }; + state.i.cfg.check_filterlist(&query)?; - let artifacts = state.i.api.list(&query.into(), true).await?; + let artifacts = state.i.api.list(&query, true).await?; Ok(Response::builder().cache().json(&artifacts)?) } From 0d14ef61420874d19f8d6b1f51e484c6ba07b4e4 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 22 Jun 2024 05:18:07 +0200 Subject: [PATCH 3/3] update README --- README.md | 190 +++++++++++++++++++++++++----- resources/screenshotPrComment.png | Bin 0 -> 20309 bytes 2 files changed, 159 insertions(+), 31 deletions(-) create mode 100644 resources/screenshotPrComment.png diff --git a/README.md b/README.md index d3458fd..2b9b2c8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Artifactview -View CI build artifacts from Forgejo/Github using your web browser! +View CI build artifacts from Forgejo/GitHub using your web browser! Forgejo and GitHub's CI systems allow you to upload files and directories as [artifacts](https://github.com/actions/upload-artifact). These can be downloaded as zip @@ -69,12 +69,112 @@ You can install the Greasemonkey userscript from the link at the bottom of the h The script adds a "View artifact" link with an eye icon next to every CI artifact on both GitHub and Forgejo. -Additionally there is a custom GitHub/Forgejo action that automatically creates a comment -under each pull request with the preview links of all artifacts created by the latest -CI run. This way every collaborator to your project has easy access to the preview. +If you want to give every collaborator to your project easy access to previews, you can +use Artifactview to automatically create a pull request comments with links to the +artifacts. + +![Pull request comment](./resources/screenshotPrComment.png) + +To accomplish that, simply add this step to your CI workflow (after uploading the +artifacts). ```yaml -TODO: showcase action +- name: 🔗 Artifactview PR comment + if: ${{ always() && github.event_name == 'pull_request' }} + run: | + curl -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" --data "{\"url\": \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\", pr: ${{ github.event.number }}}" +``` + +## API + +Artifactview does have a HTTP API to access data about the CI artifacts. To make the API +available to every site without interfering with any paths from the artifacts, the +endpoints are located within the reserved `/.well-known/api` directory. + +### Get list of artifacts of a CI run + +`GET /.well-known/api/artifacts?url=` + +`GET -------.example.com/.well-known/api/artifacts` + +**Response** + +**Note:** the difference between `download_url` and `user_download_url` is that the +first one is used by the API client and the second one is shown to the user. +`user_download_url` is only set for GitHub artifacts. Forgejo does not have different +download URLs since it does not require authentication to download artifacts. + +```json +[ + { + "id": 1, + "name": "Example", + "size": 1523222, + "expired": false, + "download_url": "https://codeberg.org/thetadev/artifactview/actions/runs/28/artifacts/Example", + "user_download_url": null + } +] +``` + +### Get metadata of the current artifact + +`GET -------.example.com/.well-known/api/artifact` + +**Response** + +```json +{ + "id": 1, + "name": "Example", + "size": 1523222, + "expired": false, + "download_url": "https://codeberg.org/thetadev/artifactview/actions/runs/28/artifacts/Example", + "user_download_url": null +} +``` + +### Get all files from the artifact + +`GET -------.example.com/.well-known/api/files` + +**Response** + +```json +[ + { "name": "example.rs", "size": 406, "crc32": "2013120c" }, + { "name": "README.md", "size": 13060, "crc32": "61c692f0" } +] +``` + +### Create a pull request comment + +`POST /.well-known/api/prComment` + +Artifactview can create a comment under a pull request containing links to view the +artifacts. This way everyone looking at a project can easily access the artifact +previews. + +To use this feature, you need to setup an access token with the permission to create +comments for every code forge you want to use (more details in the section +[Access tokens](#access-tokens)). + +To prevent abuse and spamming, this endpoint is rate-limited and Artifactview will only +create comments after it verified that the workflow matches the given pull request and +the worflow is still running. + +| JSON parameter | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `url` (string) ❕ | CI workflow URL
Example: https://codeberg.org/ThetaDev/artifactview/actions/runs/31 | +| `pr` (int) ❕ | Pull request number | +| `recreate` (bool) | If set to true, the pull request comment will be deleted and recreated if it already exists. If set to false or omitted, the comment will be edited instead. | +| `title` (string) | Comment title (default: "Latest build artifacts") | +| `artifact_titles` (map) | Set custom titles for your artifacts.
Example: `{"Hello": "🏠 Hello World ;-)"}` | + +**Response** + +```json +{ "status": 200, "msg": "created comment #2183634497" } ``` ## Setup @@ -120,27 +220,55 @@ networks: Artifactview is configured using environment variables. -| Variable | Default | Description | -| ------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | 3000 | HTTP port | -| `CACHE_DIR` | /tmp/artifactview | Temporary directory where to store the artifacts | -| `ROOT_DOMAIN` | localhost:3000 | Public hostname+port number under which artifactview is accessible. If this is configured incorrectly, artifactview will show the error message "host does not end with configured ROOT_DOMAIN" | -| `RUST_LOG` | info | Logging level | -| `NO_HTTPS` | false | Set to True if the website is served without HTTPS (used if testing artifactview without an ) | -| `MAX_ARTIFACT_SIZE` | 100000000 (100 MB) | Maximum size of the artifact zip file to be downloaded | -| `MAX_FILE_SIZE` | 100000000 (100 MB) | Maximum contained file size to be served | -| `MAX_FILE_COUNT` | 10000 | Maximum amount of files within a zip file | -| `MAX_AGE_H` | 12 | Maximum age in hours after which cached artifacts are deleted | -| `ZIP_TIMEOUT_MS` | 1000 | Maximum time in milliseconds for reading the index of a zip file. If this takes too long, the zip file is most likely excessively large or malicious (zip bomb) | -| `GITHUB_TOKEN` | - | GitHub API token for downloading artifacts. Using a fine-grained token with public read permissions is recommended | -| `MEM_CACHE_SIZE` | 50 | Artifactview keeps artifact metadata as well as the zip file indexes in memory to improve performance. The amount of cached items is adjustable. | -| `REAL_IP_HEADER` | - | Get the client IP address from a HTTP request header
If Artifactview is exposed to the network directly, this option has to be unset. If you are using a reverse proxy the proxy needs to be configured to send the actual client IP as a request header.
For most proxies this header is `x-forwarded-for`. | -| `LIMIT_ARTIFACTS_PER_MIN` | 5 | Limit the amount of downloaded artifacts per IP address and minute | -| `REPO_BLACKLIST` | - | List of sites/users/repos that can NOT be accessed. The blacklist takes precedence over the whitelist (repos included in both lists cannot be accessed)
Example: `github.com/evil-corp/world-destruction;codeberg.org/blackhat;example.org` | -| `REPO_WHITELIST` | - | List of sites/users/repos that can ONLY be accessed. If the whitelist is empty, it will be ignored and any repository can be accessed. Uses the same syntax as `REPO_BLACKLIST`. | -| `SITE_ALIASES` | - | Aliases for sites to make URLs shorter
Example: `gh => github.com;cb => codeberg.org` | -| `SUGGESTED_SITES` | codeberg.org; github.com; gitea.com | List of suggested code forges (host only, without https://, separated by `;`). If repo_whitelist is empty, this value is used for the matched sites in the userscript. The first value is used in the placeholder URL on the home page. | -| `VIEWER_MAX_SIZE` | 500000 | Maximum file size to be displayed using the viewer | +Note that some variables contain lists and maps of values. Lists need to have their +values separated with semicolons. Maps use an arrow `=>` between key and value, with +pairs separated by semicolons. + +Example list: `foo;bar`, example map: `foo=>f1;bar=>b1` + +| Variable | Default | Description | +| --------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | 3000 | HTTP port | +| `CACHE_DIR` | /tmp/artifactview | Temporary directory where to store the artifacts | +| `ROOT_DOMAIN` | localhost:3000 | Public hostname+port number under which artifactview is accessible. If this is configured incorrectly, artifactview will show the error message "host does not end with configured ROOT_DOMAIN" | +| `RUST_LOG` | info | Logging level | +| `NO_HTTPS` | false | Set to True if the website is served without HTTPS (used if testing artifactview without an ) | +| `MAX_ARTIFACT_SIZE` | 100000000 (100 MB) | Maximum size of the artifact zip file to be downloaded | +| `MAX_FILE_SIZE` | 100000000 (100 MB) | Maximum contained file size to be served | +| `MAX_FILE_COUNT` | 10000 | Maximum amount of files within a zip file | +| `MAX_AGE_H` | 12 | Maximum age in hours after which cached artifacts are deleted | +| `ZIP_TIMEOUT_MS` | 1000 | Maximum time in milliseconds for reading the index of a zip file. If this takes too long, the zip file is most likely excessively large or malicious (zip bomb) | +| `GITHUB_TOKEN` | - | GitHub API token for downloading artifacts and creating PR comments. Using a fine-grained token with public read permissions is recommended | +| `FORGEJO_TOKENS` | - | Forgejo API tokens for creating PR comments
Example: `codeberg.org=>fc010f65348468d05e570806275528c936ce93a4` | +| `MEM_CACHE_SIZE` | 50 | Artifactview keeps artifact metadata as well as the zip file indexes in memory to improve performance. The amount of cached items is adjustable. | +| `REAL_IP_HEADER` | - | Get the client IP address from a HTTP request header
If Artifactview is exposed to the network directly, this option has to be unset. If you are using a reverse proxy the proxy needs to be configured to send the actual client IP as a request header.
For most proxies this header is `x-forwarded-for`. | +| `LIMIT_ARTIFACTS_PER_MIN` | 5 | Limit the amount of downloaded artifacts per IP address and minute to prevent excessive resource usage. | +| `LIMIT_PR_COMMENTS_PER_MIN` | 5 | Limit the amount of pull request comment requests per IP address and minute to prevent spamming. | +| `REPO_BLACKLIST` | - | List of sites/users/repos that can NOT be accessed. The blacklist takes precedence over the whitelist (repos included in both lists cannot be accessed)
Example: `github.com/evil-corp/world-destruction;codeberg.org/blackhat;example.org` | +| `REPO_WHITELIST` | - | List of sites/users/repos that can ONLY be accessed. If the whitelist is empty, it will be ignored and any repository can be accessed. Uses the same syntax as `REPO_BLACKLIST`. | +| `SITE_ALIASES` | - | Aliases for sites to make URLs shorter
Example: `gh => github.com;cb => codeberg.org` | +| `SUGGESTED_SITES` | codeberg.org; github.com; gitea.com | List of suggested code forges (host only, without https://, separated by `;`). If repo_whitelist is empty, this value is used for the matched sites in the userscript. The first value is used in the placeholder URL on the home page. | +| `VIEWER_MAX_SIZE` | 500000 | Maximum file size to be displayed using the viewer | + +### Access tokens + +GitHub does not allow downloading artifacts for public repositories for unauthenticated +users. So you need to setup an access token to use Artifactview with GitHub. These are +the permissions that need to be enabled: + +- Repository access: All repositories +- Repository permissions: Pull requests (Read and write) + +Forgejo does not require access tokens to download artifacts on public repositories, so +you only need to create a token if you want to use the `prComment`-API. In this case, +the token needs the following permissions: + +- Repository and Organization Access: Public only +- issue: Read and write +- user: Read (for determining own user ID) + +Note that if you are using Artifactview to create pull request comments, it is +recommended to create a second bot account instead of using your main account. ## Technical details @@ -154,8 +282,8 @@ Example: `https://github-com--theta-dev--example-project--4-11.example.com` The reason for using subdomains instead of URL paths is that many websites expect to be served from a separate subdomain and access resources using absolute paths. Using URLs like `example.com/github.com/theta-dev/example-project/4/11/path/to/file` would make the -application easier to host, but it would not be possible to simply preview a -React/Vue/Svelte web project. +application easier to host, but it would not be possible to preview a React/Vue/Svelte +web project. Since domains only allow letters, numbers and dashes but repository names allow dots and underscores, these escape sequences are used to access repositories with special @@ -187,7 +315,7 @@ website (like `.well-known/acme-challenge` for issuing TLS certificates), Artifa will serve no files from the `.well-known` folder. There is a configurable limit for both the maximum downloaded artifact size and the -maximum size of individual files to be served (100MB by default). Additionally there is +maximum size of individual files to be served (100 MB by default). Additionally there is a configurable timeout for the zip file indexing operation. These measures should -protect the server againt denial-of-service attacks like overfilling the server drive or -uploading zip bombs. +protect the server against denial-of-service attacks like overfilling the server drive +or uploading zip bombs. diff --git a/resources/screenshotPrComment.png b/resources/screenshotPrComment.png new file mode 100644 index 0000000000000000000000000000000000000000..68fcc669089ef1541031953e1bea7b38c4954512 GIT binary patch literal 20309 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
    u7hRNq)1BfNwLN=)2P}cR|oG7Pt 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 VNH5OI$Qxh3w=#POt~2#|@ego*FJS-x literal 0 HcmV?d00001