diff --git a/Cargo.lock b/Cargo.lock index b444204..d6d6299 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,6 @@ dependencies = [ "reqwest", "rstest", "scraper", - "secrecy", "serde", "serde-env", "serde-hex", @@ -181,13 +180,11 @@ dependencies = [ "syntect", "temp_testdir", "thiserror", - "time", "tokio", "tokio-util", "tower-http", "tracing", "tracing-subscriber", - "unic-emoji-char", "url", "yarte", "yarte_helpers", @@ -2471,16 +2468,6 @@ 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" @@ -3145,47 +3132,6 @@ 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 fdd21b8..9197504 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ axum = { version = "0.7.5", default-features = false, features = [ "http1", "http2", "json", - "query", "tokio", "tracing", ] } @@ -53,7 +52,6 @@ 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" @@ -67,13 +65,11 @@ 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 2b9b2c8..0a610f8 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,23 @@ # 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. -That's why I developed Artifactview. It is a small web application that fetches these CI -artifacts and serves their contents. +Artifactview is a small web application that fetches these CI artifacts and displays +their contents. -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. +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. -## Features +Artifactview displays a file listing if there is no `index.html` or fallback page +present, so you can browse artifacts that dont contain websites. -- 📦 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 +![Artifact file listing](resources/screenshotFiles.png) ## How to use @@ -32,151 +27,6 @@ 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. - -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 -- 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 You can run artifactview using the docker image provided under @@ -220,55 +70,27 @@ networks: Artifactview is configured using environment variables. -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. +| 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 | ## Technical details @@ -282,8 +104,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 preview a React/Vue/Svelte -web project. +application easier to host, but it would not be possible to simply 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 @@ -315,7 +137,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 (100 MB by default). Additionally there is +maximum size of individual files to be served (100MB by default). Additionally there is a configurable timeout for the zip file indexing operation. These measures should -protect the server against denial-of-service attacks like overfilling the server drive -or uploading zip bombs. +protect the server againt denial-of-service attacks like overfilling the server drive or +uploading zip bombs. diff --git a/resources/screenshotCode.png b/resources/screenshotCode.png deleted file mode 100644 index 7052628..0000000 Binary files a/resources/screenshotCode.png and /dev/null differ diff --git a/resources/screenshotJUnit.png b/resources/screenshotJUnit.png deleted file mode 100644 index 3a05070..0000000 Binary files a/resources/screenshotJUnit.png and /dev/null differ diff --git a/resources/screenshotPrComment.png b/resources/screenshotPrComment.png deleted file mode 100644 index 68fcc66..0000000 Binary files a/resources/screenshotPrComment.png and /dev/null differ diff --git a/src/app.rs b/src/app.rs index 243a207..8367c5a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,24 +1,17 @@ use std::{ - collections::{BTreeMap, HashMap}, - fmt::Write, - net::{IpAddr, SocketAddr}, - ops::Bound, - path::Path, - str::FromStr, - sync::Arc, + collections::BTreeMap, net::SocketAddr, ops::Bound, path::Path, str::FromStr, sync::Arc, }; use async_zip::tokio::read::ZipEntryReader; use axum::{ body::Body, - extract::{Host, Query as XQuery, Request, State}, + extract::{Host, Request, State}, http::{Response, Uri}, response::{IntoResponse, Redirect}, - routing::{any, get, post}, - Json, RequestExt, Router, + routing::{any, get}, + Router, }; use futures_lite::AsyncReadExt as LiteAsyncReadExt; -use governor::{Quota, RateLimiter}; use headers::{ContentType, HeaderMapExt}; use http::{HeaderMap, StatusCode}; use serde::Deserialize; @@ -36,7 +29,7 @@ use tower_http::{ }; use crate::{ - artifact_api::{Artifact, ArtifactApi, WorkflowRun}, + artifact_api::ArtifactApi, cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile}, config::Config, error::Error, @@ -59,12 +52,6 @@ struct AppInner { cache: Cache, api: ArtifactApi, viewers: Viewers, - lim_pr_comment: Option< - governor::DefaultKeyedRateLimiter< - IpAddr, - governor::middleware::NoOpMiddleware, - >, - >, } impl Default for App { @@ -78,25 +65,6 @@ struct FileQparams { viewer: Option, } -#[derive(Deserialize)] -struct UrlQuery { - url: 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"); @@ -158,7 +126,6 @@ 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 })) @@ -364,9 +331,8 @@ impl App { .query() .and_then(|q| serde_urlencoded::from_str::(q).ok()) { - let query = - RunQuery::from_forge_url_alias(¶ms.url, &state.i.cfg.load().site_aliases)?; - let artifacts = state.i.api.list(&query, true).await?; + let query = RunQuery::from_forge_url(¶ms.url, &state.i.cfg.load().site_aliases)?; + let artifacts = state.i.api.list(&query).await?; if artifacts.is_empty() { Err(Error::NotFound("artifacts".into())) @@ -579,7 +545,7 @@ impl App { .typed_header(headers::ContentLength(content_length)) .typed_header( headers::ContentRange::bytes(range, total_len) - .map_err(|e| Error::Other(e.to_string().into()))?, + .map_err(|e| Error::Internal(e.to_string().into()))?, ) .body(Body::from_stream(ReaderStream::new( bufreader.take(content_length), @@ -596,18 +562,11 @@ impl App { async fn get_artifacts( State(state): State, Host(host): Host, - url_query: XQuery, ) -> Result, ErrorJson> { - 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() - } - }; - + 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, true).await?; + let artifacts = state.i.api.list(&query.into()).await?; Ok(Response::builder().cache().json(&artifacts)?) } @@ -644,83 +603,6 @@ 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()) @@ -760,14 +642,10 @@ 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, }), } } @@ -811,175 +689,3 @@ 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 69c6068..70885ca 100644 --- a/src/artifact_api.rs +++ b/src/artifact_api.rs @@ -1,15 +1,12 @@ //! API-Client to fetch CI artifacts from Github and Forgejo + use std::path::Path; use futures_lite::StreamExt; -use http::{header, Method}; -use once_cell::sync::Lazy; +use http::header; use quick_cache::sync::Cache as QuickCache; -use regex::Regex; use reqwest::{Client, ClientBuilder, IntoUrl, RequestBuilder, Response, Url}; -use secrecy::ExposeSecret; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use time::OffsetDateTime; +use serde::{Deserialize, Serialize}; use tokio::{fs::File, io::AsyncWriteExt}; use crate::{ @@ -22,10 +19,9 @@ pub struct ArtifactApi { http: Client, cfg: Config, qc: QuickCache>, - user_ids: QuickCache, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct Artifact { pub id: u64, pub name: String, @@ -39,7 +35,7 @@ pub struct Artifact { pub user_download_url: Option, } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] struct GithubArtifact { id: u64, name: String, @@ -48,24 +44,24 @@ struct GithubArtifact { archive_download_url: String, } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] struct ForgejoArtifact { name: String, size: u64, status: ForgejoArtifactStatus, } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] struct ApiError { message: String, } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] struct ArtifactsWrap { artifacts: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] #[serde(rename_all = "snake_case")] enum ForgejoArtifactStatus { Completed, @@ -104,154 +100,6 @@ 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 { @@ -264,30 +112,26 @@ 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, cached: bool) -> Result> { + pub async fn list(&self, query: &RunQuery) -> Result> { let cache_key = query.cache_key(); - 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 - } + 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 } pub async fn fetch(&self, query: &ArtifactQuery) -> Result { @@ -333,7 +177,7 @@ impl ArtifactApi { let url = Url::parse(&artifact.download_url)?; let req = if url.domain() == Some("api.github.com") { - self.get_github_any(url) + self.get_github(url) } else { self.http.get(url) }; @@ -368,7 +212,8 @@ impl ArtifactApi { ); let resp = self - .get_forgejo(url) + .http + .get(url) .send() .await? .error_for_status()? @@ -391,8 +236,10 @@ impl ArtifactApi { query.user, query.repo, query.run ); - let resp = - Self::send_api_req::>(self.get_github(url)).await?; + let resp = Self::handle_github_error(self.get_github(url).send().await?) + .await? + .json::>() + .await?; Ok(resp .artifacts @@ -407,12 +254,14 @@ impl ArtifactApi { query.user, query.repo, query.artifact ); - let artifact = Self::send_api_req::(self.get_github(url)).await?; + let artifact = Self::handle_github_error(self.get_github(url).send().await?) + .await? + .json::() + .await?; Ok(artifact.into_artifact(query.as_ref())) } - async fn send_api_req_empty(req: RequestBuilder) -> Result { - let resp = req.send().await?; + async fn handle_github_error(resp: Response) -> Result { if let Err(e) = resp.error_for_status_ref() { let status = resp.status(); let msg = resp.json::().await.ok(); @@ -425,330 +274,21 @@ impl ArtifactApi { } } - 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 { + fn get_github(&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.expose_secret()), - ); + builder = builder.header(header::AUTHORIZATION, format!("Bearer {github_token}")); } 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 time::macros::datetime; - - use crate::{ - config::Config, - query::{ArtifactQuery, RunQuery}, - }; + use crate::{config::Config, query::ArtifactQuery}; use super::ArtifactApi; @@ -781,31 +321,4 @@ 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 f349558..737e514 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::Other("no file modified time".into()))?; + .map_err(|_| Error::Internal("no file modified time".into()))?; let accessed = metadata .accessed() - .map_err(|_| Error::Other("no file accessed time".into()))?; + .map_err(|_| Error::Internal("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::Other(e.to_string().into()))? + .map_err(|e| Error::Internal(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::Other("no file accessed time".into()))?; + .map_err(|_| Error::Internal("no file accessed time".into()))?; if now .duration_since(accessed) - .map_err(|e| Error::Other(e.to_string().into()))? + .map_err(|e| Error::Internal(e.to_string().into()))? > max_age { let path = entry.path(); @@ -289,7 +289,7 @@ impl CacheEntry { name, last_modified: meta .modified() - .map_err(|_| Error::Other("no file modified time".into()))?, + .map_err(|_| Error::Internal("no file modified time".into()))?, }) } diff --git a/src/config.rs b/src/config.rs index cbc2e66..0c524f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,12 +5,11 @@ use std::{ sync::Arc, }; -use secrecy::Secret; use serde::Deserialize; use crate::{ error::{Error, Result}, - query::{Query, QueryFilterList}, + query::{ArtifactQuery, QueryFilterList}, }; #[derive(Clone)] @@ -49,9 +48,7 @@ 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>, - /// Forgejo/Gitea API tokens by host - pub forgejo_tokens: HashMap>, + pub github_token: Option, /// Number of artifact indexes to keep in memory pub mem_cache_size: usize, /// Get the client IP address from a HTTP request header @@ -64,8 +61,6 @@ 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 @@ -94,11 +89,9 @@ 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![ @@ -131,7 +124,7 @@ impl ConfigData { impl Config { pub fn new() -> Result { let data = - envy::from_env::().map_err(|e| Error::Other(e.to_string().into()))?; + envy::from_env::().map_err(|e| Error::Internal(e.to_string().into()))?; Self::from_data(data) } @@ -180,7 +173,7 @@ impl Config { .unwrap_or("codeberg.org") } - pub fn check_filterlist(&self, query: &Q) -> Result<()> { + pub fn check_filterlist(&self, query: &ArtifactQuery) -> 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 0c6d30a..8d3097f 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("Error: {0}")] - Other(Cow<'static, str>), + #[error("Internal error: {0}")] + Internal(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::Other(value.to_string().into()) + Self::Internal(value.to_string().into()) } } impl From for Error { fn from(value: url::ParseError) -> Self { - Self::Other(value.to_string().into()) + Self::Internal(value.to_string().into()) } } diff --git a/src/query.rs b/src/query.rs index 8060f11..ddf206c 100644 --- a/src/query.rs +++ b/src/query.rs @@ -148,11 +148,7 @@ impl ArtifactQuery { } impl RunQuery { - 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 { + pub fn from_forge_url(url: &str, aliases: &HashMap) -> Result { let (host, mut path_segs) = util::parse_url(url)?; let user = path_segs @@ -335,12 +331,12 @@ impl FromStr for QueryFilter { if let Some(user) = &user { if !RE_REPO_NAME.is_match(user) { - return Err(Error::Other("invalid username".into())); + return Err(Error::Internal("invalid username".into())); } } if let Some(repo) = &repo { if !RE_REPO_NAME.is_match(repo) { - return Err(Error::Other("invalid repository name".into())); + return Err(Error::Internal("invalid repository name".into())); } } @@ -374,7 +370,7 @@ impl FromStr for QueryFilterList { } impl QueryFilterList { - pub fn passes(&self, query: &Q, blacklist: bool) -> bool { + pub fn passes(&self, query: &ArtifactQuery, 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 deleted file mode 100644 index d86a280..0000000 --- a/src/snapshots/artifactview__app__tests__pr_comment_1.snap +++ /dev/null @@ -1,9 +0,0 @@ ---- -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 deleted file mode 100644 index 8d54b0d..0000000 --- a/src/snapshots/artifactview__app__tests__pr_comment_2.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -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 deleted file mode 100644 index 9cfbd08..0000000 --- a/src/snapshots/artifactview__app__tests__pr_comment_3.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -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 bdd198c..5ce1238 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::Other("could get request ip address".into()))? + .ok_or(Error::Internal("could get request ip address".into()))? .0; Ok(socket_addr.ip()) } @@ -263,15 +263,6 @@ 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 { @@ -293,15 +284,6 @@ 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 deleted file mode 100644 index 023f85b..0000000 --- a/tests/testfiles/giteaWorkflowRun.json +++ /dev/null @@ -1,320 +0,0 @@ -{ - "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 deleted file mode 100644 index fbdb46e..0000000 --- a/tests/testfiles/githubWorkflowRun.json +++ /dev/null @@ -1,220 +0,0 @@ -{ - "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" - } -}