Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

45 changed files with 989 additions and 3653 deletions

View file

@ -4,7 +4,7 @@ on:
branches: branches:
- main - main
paths: paths:
- ".forgejo/workflows/artifact.yaml" - ".forgejo/artifact.yaml"
jobs: jobs:
artifact: artifact:
@ -12,32 +12,4 @@ jobs:
steps: steps:
- name: 👁️ Checkout repository - name: 👁️ Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Create test artifacts - name: Build artifact
run: |
# A1
mkdir -p artifacts/a1/mdbook
cd artifacts
git clone https://github.com/rust-lang/mdBook
cd mdBook/test_book
mdbook build -d ../../a1/mdbook
cd ../..
cp -r ../tests/testfiles/junit ../tests/testfiles/sites/style.css ../tests/testfiles/sites/404.html ../tests/testfiles/sites/example.rs ../README.md a1
# A2
git clone https://github.com/SveltePress/sveltepress
cd sveltepress/packages/docs-site
pnpm i
pnpm build
mv dist ../../../a2
- name: Upload A1 (Example)
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: Example
path: artifacts/a1
- name: Upload A2 (SveltePress)
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: SveltePress
path: artifacts/a2

View file

@ -1,8 +1,6 @@
name: CI name: CI
on: on:
push: push:
branches:
- "main"
pull_request: pull_request:
jobs: jobs:
@ -27,12 +25,50 @@ jobs:
with: with:
name: test name: test
path: target/nextest/ci/junit.xml path: target/nextest/ci/junit.xml
- name: 🔗 Artifactview PR comment
if: ${{ always() && github.event_name == 'pull_request' }}
run: |
if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"
echo "Pull: ${{ github.event.number }}"
curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \ release:
--data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "pr": ${{ github.event.number }}, "artifact_titles": {"test":"🧪 Test report"}, "artifact_paths": {"test":"/junit.xml?viewer=1"}}' runs-on: cimaster-latest
needs: test
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: 👁️ Checkout repository
uses: actions/checkout@v4
- name: ⚒️ Build application
run: |
PKG_CONFIG_SYSROOT_DIR=/usr/x86_64-linux-gnu cargo build --release --target x86_64-unknown-linux-gnu
PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu cargo build --release --target aarch64-unknown-linux-gnu
- name: 🐋 Build docker image
uses: https://code.thetadev.de/ThetaDev/action-kaniko@v1
with:
image: thetadev256/artifactview
username: thetadev256
password: ${{ secrets.DOCKER_TOKEN }}
tag: ${{ github.ref_name }}
tag_with_latest: ${{ startsWith(github.ref, 'refs/tags/v') }}
platforms: "linux/amd64,linux/arm64"
- name: Prepare release
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
mkdir dist
tar -cJf dist/artifactview-x86_64-${{ github.ref_name }}.tar.xz -C target/x86_64-unknown-linux-gnu/release artifactview
tar -cJf dist/artifactview-aarch64-${{ github.ref_name }}.tar.xz -C target/aarch64-unknown-linux-gnu/release artifactview
{
echo 'CHANGELOG<<END_OF_FILE'
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' CHANGELOG.md
echo END_OF_FILE
} >> "$GITHUB_ENV"
- name: 🎉 Publish release
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
uses: https://gitea.com/actions/release-action@main
with:
title: "artifactview ${{ github.ref_name }}"
body: "${{ env.CHANGELOG }}"
files: dist/*
- name: 🚀 Deploy to server
run: |
curl -s -H "Authorization: Bearer ${{ secrets.THETADEV_DE_WATCHTOWER_TOKEN }}" https://watchtower.thetadev.de/v1/update

View file

@ -1,23 +0,0 @@
name: DockerHub README
on:
push:
branches:
- main
paths:
- "README.md"
- ".forgejo/workflows/docker-readme.yaml"
jobs:
update:
runs-on: cimaster-latest
steps:
- name: 👁️ Checkout repository
uses: actions/checkout@v4
- name: Docker Hub Description
uses: https://github.com/peter-evans/dockerhub-description@v4
with:
username: thetadev256
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: thetadev256/artifactview
enable-url-completion: true

View file

@ -1,51 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: cimaster-latest
steps:
- name: 👁️ Checkout repository
uses: actions/checkout@v4
- name: ⚒️ Build application
run: |
PKG_CONFIG_SYSROOT_DIR=/usr/x86_64-linux-gnu cargo build --release --target x86_64-unknown-linux-gnu
PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu cargo build --release --target aarch64-unknown-linux-gnu
- name: 🐋 Build docker image
uses: https://code.thetadev.de/ThetaDev/action-kaniko@v1
with:
image: thetadev256/artifactview
username: thetadev256
password: ${{ secrets.DOCKER_TOKEN }}
tag: ${{ github.ref_name }}
tag_with_latest: ${{ startsWith(github.ref, 'refs/tags/v') }}
platforms: "linux/amd64,linux/arm64"
- name: Prepare release
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
run: |
mkdir dist
tar -cJf dist/artifactview-x86_64-${{ github.ref_name }}.tar.xz -C target/x86_64-unknown-linux-gnu/release artifactview
tar -cJf dist/artifactview-aarch64-${{ github.ref_name }}.tar.xz -C target/aarch64-unknown-linux-gnu/release artifactview
{
echo 'CHANGELOG<<END_OF_FILE'
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' CHANGELOG.md
echo END_OF_FILE
} >> "$GITHUB_ENV"
- name: 🎉 Publish release
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
uses: https://gitea.com/actions/release-action@main
with:
title: "artifactview ${{ github.ref_name }}"
body: "${{ env.CHANGELOG }}"
files: dist/*
- name: 🚀 Deploy to server
run: |
curl -SsL --fail-with-body -H "Authorization: Bearer ${{ secrets.THETADEV_DE_WATCHTOWER_TOKEN }}" https://watchtower.thetadev.de/v1/update

View file

@ -1,63 +0,0 @@
name: renovate
on:
push:
branches: ["main"]
paths:
- ".forgejo/workflows/renovate.yaml"
- "renovate.json"
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
env:
RENOVATE_REPOSITORIES: ${{ github.repository }}
jobs:
renovate:
runs-on: docker
container:
image: renovate/renovate:39
steps:
- name: Load renovate repo cache
uses: actions/cache/restore@v4
with:
path: |
.tmp/cache/renovate/repository
.tmp/cache/renovate/renovate-cache-sqlite
.tmp/osv
key: repo-cache-${{ github.run_id }}
restore-keys: |
repo-cache-
- name: Run renovate
run: renovate
env:
LOG_LEVEL: info
RENOVATE_BASE_DIR: ${{ github.workspace }}/.tmp
RENOVATE_ENDPOINT: ${{ github.server_url }}
RENOVATE_PLATFORM: gitea
RENOVATE_REPOSITORY_CACHE: 'enabled'
RENOVATE_TOKEN: ${{ secrets.FORGEJO_CI_BOT_TOKEN }}
GITHUB_COM_TOKEN: ${{ secrets.GH_PUBLIC_TOKEN }}
RENOVATE_GIT_AUTHOR: 'Renovate Bot <forgejo-renovate-action@forgejo.org>'
RENOVATE_X_SQLITE_PACKAGE_CACHE: true
GIT_AUTHOR_NAME: 'Renovate Bot'
GIT_AUTHOR_EMAIL: 'forgejo-renovate-action@forgejo.org'
GIT_COMMITTER_NAME: 'Renovate Bot'
GIT_COMMITTER_EMAIL: 'forgejo-renovate-action@forgejo.org'
OSV_OFFLINE_ROOT_DIR: ${{ github.workspace }}/.tmp/osv
- name: Save renovate repo cache
if: always() && env.RENOVATE_DRY_RUN != 'full'
uses: actions/cache/save@v4
with:
path: |
.tmp/cache/renovate/repository
.tmp/cache/renovate/renovate-cache-sqlite
.tmp/osv
key: repo-cache-${{ github.run_id }}

View file

@ -3,280 +3,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v0.4.8](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.7..v0.4.8) - 2025-01-26
### 🐛 Bug Fixes
- Lifetime-related lints - ([e20f6fb](https://codeberg.org/ThetaDev/artifactview/commit/e20f6fb92e86751222d2c5143ee384cdbea1159d))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate async-compression to v0.4.15 (#85) - ([4f4716c](https://codeberg.org/ThetaDev/artifactview/commit/4f4716cdd86c317ede2b381d375ef8e736aee240))
- *(deps)* Update rust crate async-compression to v0.4.16 (#86) - ([9592da3](https://codeberg.org/ThetaDev/artifactview/commit/9592da3d6e2c2223174fcc459e94f29bf5067ead))
- *(deps)* Update rust crate serde_json to v1.0.129 (#87) - ([2e46d37](https://codeberg.org/ThetaDev/artifactview/commit/2e46d3795089ef7b2739db4d216f4db99f792071))
- *(deps)* Update rust crate serde_json to v1.0.131 (#88) - ([992f995](https://codeberg.org/ThetaDev/artifactview/commit/992f9954414af550fce90c9c7424ab0da2296875))
- *(deps)* Update rust crate governor to v0.6.4 (#89) - ([f1d9897](https://codeberg.org/ThetaDev/artifactview/commit/f1d9897e832b5cdb99fd81edcb38d27bd6b445f8))
- *(deps)* Update rust crate serde_json to v1.0.132 (#90) - ([600d18d](https://codeberg.org/ThetaDev/artifactview/commit/600d18d05b356c641a32b93b2407a1f23e2370c4))
- *(deps)* Update rust crate async-compression to v0.4.17 (#91) - ([0b3c032](https://codeberg.org/ThetaDev/artifactview/commit/0b3c0325a50cd456cb9b62f97e916f0760785a3c))
- *(deps)* Update rust crate governor to 0.7.0 (#92) - ([cbd304c](https://codeberg.org/ThetaDev/artifactview/commit/cbd304c84195983596156a95c26d65a89e93df8a))
- *(deps)* Update rust crate thiserror to v1.0.65 (#94) - ([2df196a](https://codeberg.org/ThetaDev/artifactview/commit/2df196a2e666e8186d9ff66e885123c8a48c743c))
- *(deps)* Update rust crate serde to v1.0.213 (#93) - ([e5b9105](https://codeberg.org/ThetaDev/artifactview/commit/e5b9105da1fb0c63584cc409b5dd98c1fe045f9b))
- *(deps)* Update rust crate tokio to v1.41.0 (#95) - ([1dc4fe2](https://codeberg.org/ThetaDev/artifactview/commit/1dc4fe225c01f237f6a698fced7eff72cfb8ee85))
- *(deps)* Update rust crate pin-project to v1.1.7 (#96) - ([c82bccc](https://codeberg.org/ThetaDev/artifactview/commit/c82bccca9098281fa42ded5d5527eadb3cbcce88))
- *(deps)* Update rust crate regex to v1.11.1 (#97) - ([613815a](https://codeberg.org/ThetaDev/artifactview/commit/613815aa1ebc01643e117ca9fafcb37bfe7d29d7))
- *(deps)* Update rust crate axum-test to v16.3.0 (#98) - ([b2070ec](https://codeberg.org/ThetaDev/artifactview/commit/b2070ec460e45eeb9997885bd7cf54913f9f4183))
- *(deps)* Update rust crate insta to v1.41.0 (#99) - ([5aec8d6](https://codeberg.org/ThetaDev/artifactview/commit/5aec8d677f089ff5092fe78655170d52e544baeb))
- *(deps)* Update rust crate quick-xml to 0.37.0 (#100) - ([72e20d4](https://codeberg.org/ThetaDev/artifactview/commit/72e20d413e80b86d49ea0b275ce6aece99d75314))
- *(deps)* Update rust crate reqwest to v0.12.9 (#101) - ([d45e8e6](https://codeberg.org/ThetaDev/artifactview/commit/d45e8e63c9ce4067a05839643581b9f42e4048ec))
- *(deps)* Update rust crate serde to v1.0.214 (#102) - ([2a1ebd7](https://codeberg.org/ThetaDev/artifactview/commit/2a1ebd7b854ca82cceb768202e4d9ee984007311))
- *(deps)* Update rust crate futures-lite to v2.4.0 (#103) - ([3bda063](https://codeberg.org/ThetaDev/artifactview/commit/3bda06357809e68c0804aa425e625893af1bbde2))
- *(deps)* Update rust crate insta to v1.41.1 (#104) - ([a406bff](https://codeberg.org/ThetaDev/artifactview/commit/a406bffabeeb2627cd5ef74f3520add6eb0a8d6c))
- *(deps)* Update rust crate thiserror to v1.0.66 (#105) - ([8f89fc9](https://codeberg.org/ThetaDev/artifactview/commit/8f89fc9953370c4325c63762e4451d37a1a4a64a))
- *(deps)* Update rust crate scraper to 0.21.0 (#106) - ([1f00bbf](https://codeberg.org/ThetaDev/artifactview/commit/1f00bbfac53521cf0f76cf6fc4bf23e7a5e10562))
- *(deps)* Update rust crate thiserror to v1.0.67 (#107) - ([39a76ea](https://codeberg.org/ThetaDev/artifactview/commit/39a76eaa334d3f57e30e3ba95eb781dfa7aee1ee))
- *(deps)* Update rust crate thiserror to v1.0.68 (#108) - ([a48af07](https://codeberg.org/ThetaDev/artifactview/commit/a48af07d936a77977d602649fe579941b2cd2630))
- *(deps)* Update rust crate url to v2.5.3 (#109) - ([44cc0c1](https://codeberg.org/ThetaDev/artifactview/commit/44cc0c10103eb8fc10ddcc17e559479fadcbe4f1))
- *(deps)* Update rust crate tokio to v1.41.1 (#111) - ([dd809ce](https://codeberg.org/ThetaDev/artifactview/commit/dd809ce3f322fe8dafadeb802be7fcf905aa2f02))
- *(deps)* Update rust crate thiserror to v2 (#110) - ([8cb636c](https://codeberg.org/ThetaDev/artifactview/commit/8cb636ccc9c93800e6e98522de5c38bb05e76fdb))
- *(deps)* Update rust crate futures-lite to v2.5.0 (#112) - ([c05eb56](https://codeberg.org/ThetaDev/artifactview/commit/c05eb562a9d10945ee534ef50208aeb004023c51))
- *(deps)* Update rust crate thiserror to v2.0.3 (#113) - ([a695cef](https://codeberg.org/ThetaDev/artifactview/commit/a695cef57d5c73492d806958290bb37bd9613125))
- *(deps)* Update rust crate serde to v1.0.215 (#114) - ([3497592](https://codeberg.org/ThetaDev/artifactview/commit/34975924b1e1c3d5367346a1e0274a435496091f))
- *(deps)* Update rust crate flate2 to v1.0.35 (#115) - ([0b9498c](https://codeberg.org/ThetaDev/artifactview/commit/0b9498c541c0188fd7c82a99b2f778251b831df3))
- *(deps)* Update rust crate axum to v0.7.8 (#116) - ([79623d9](https://codeberg.org/ThetaDev/artifactview/commit/79623d9bc2d40e43374c4a2934d6bbac1235bae7))
- *(deps)* Update rust crate axum-extra to v0.9.5 (#117) - ([b35cfe3](https://codeberg.org/ThetaDev/artifactview/commit/b35cfe3f4fe402943ee3238b671c18449c5437ba))
- *(deps)* Update rust crate axum-test to v16.4.0 (#118) - ([e370001](https://codeberg.org/ThetaDev/artifactview/commit/e37000143e752b4bc496797de2410be33d3adf2b))
- *(deps)* Update rust crate axum to v0.7.9 (#119) - ([cab58d2](https://codeberg.org/ThetaDev/artifactview/commit/cab58d284e6b9f2e6b730d9b3f6d648d0955832d))
- *(deps)* Update rust crate quick-xml to v0.37.1 (#121) - ([dffcd16](https://codeberg.org/ThetaDev/artifactview/commit/dffcd16a60b456e9ed547b2a01342df3585e607f))
- *(deps)* Update rust crate axum-extra to v0.9.6 (#120) - ([4cf0084](https://codeberg.org/ThetaDev/artifactview/commit/4cf0084e24969d15149468de42cf6ed15e1169f5))
- *(deps)* Update rust crate serde_json to v1.0.133 (#122) - ([5231609](https://codeberg.org/ThetaDev/artifactview/commit/52316093cdad4ceca274e4c65035842e0413892e))
- *(deps)* Update rust crate tower-http to v0.6.2 (#123) - ([8158497](https://codeberg.org/ThetaDev/artifactview/commit/8158497a73367fb2280d48350e3afa868c006d65))
- *(deps)* Update rust crate zip to v2.2.1 (#124) - ([81c8521](https://codeberg.org/ThetaDev/artifactview/commit/81c852126ca45172bd00f75a5007263fefb5967b))
- *(deps)* Update rust crate url to v2.5.4 (#125) - ([c99dfa8](https://codeberg.org/ThetaDev/artifactview/commit/c99dfa809175e84261245b2680a964b95d81b4e3))
- *(deps)* Update rust crate comrak to 0.30.0 (#126) - ([389dd6f](https://codeberg.org/ThetaDev/artifactview/commit/389dd6f536b9044a8b9675c3ad34fa218028d154))
- *(deps)* Update rust crate async-compression to v0.4.18 (#127) - ([05f20f4](https://codeberg.org/ThetaDev/artifactview/commit/05f20f44ac4910e600f318d656d376d52fd6b131))
- *(deps)* Update rust crate comrak to 0.31.0 (#128) - ([5fd14aa](https://codeberg.org/ThetaDev/artifactview/commit/5fd14aada8f310fdeb0b2dc33a15de0b195ebc81))
- *(deps)* Update rust crate tracing to v0.1.41 (#129) - ([ab3479f](https://codeberg.org/ThetaDev/artifactview/commit/ab3479f0d1b3a30413df654cc9f2e7a52081b542))
- *(deps)* Update rust crate tracing-subscriber to v0.3.19 (#130) - ([1f9847b](https://codeberg.org/ThetaDev/artifactview/commit/1f9847b3edd73c1e905feb88087ee4325364ddda))
- *(deps)* Update rust crate thiserror to v2.0.4 (#131) - ([e9d1226](https://codeberg.org/ThetaDev/artifactview/commit/e9d122639022d3b5b68b4626e00442a81acf85f5))
- *(deps)* Update rust crate time to v0.3.37 (#132) - ([6fc7263](https://codeberg.org/ThetaDev/artifactview/commit/6fc7263f5996abefdb71fd5ecae277ec707cbdd9))
- *(deps)* Update rust crate http to v1.2.0 (#133) - ([5f517ae](https://codeberg.org/ThetaDev/artifactview/commit/5f517ae6c784e4fbc99ca69650d85621e90f776c))
- *(deps)* Update rust crate tokio to v1.42.0 (#134) - ([b15c4b0](https://codeberg.org/ThetaDev/artifactview/commit/b15c4b009a268cd4227846f166131aad5628f87d))
- *(deps)* Update rust crate tokio-util to v0.7.13 (#135) - ([f43f06c](https://codeberg.org/ThetaDev/artifactview/commit/f43f06c33462c15a8a752493f14c86d9dbde0e8d))
- *(deps)* Update rust crate thiserror to v2.0.5 (#136) - ([94c589c](https://codeberg.org/ThetaDev/artifactview/commit/94c589c20936c8e56be3147fd01e51d8b25e617f))
- *(deps)* Update rust crate thiserror to v2.0.6 (#137) - ([5e83ab5](https://codeberg.org/ThetaDev/artifactview/commit/5e83ab510614db696a7245ad5027c717b3493cab))
- *(deps)* Update rust crate chrono to v0.4.39 (#138) - ([8e9c5aa](https://codeberg.org/ThetaDev/artifactview/commit/8e9c5aad48378c2fa1a1d3d370b96de2fca5dd86))
- *(deps)* Update rust crate governor to 0.8.0 (#139) - ([b24136e](https://codeberg.org/ThetaDev/artifactview/commit/b24136ec597d2b4c579176a6a7845878549478bb))
- *(deps)* Update rust crate scraper to 0.22.0 (#140) - ([f48c570](https://codeberg.org/ThetaDev/artifactview/commit/f48c57021505091b22c36bd49bd4527e652b8d78))
- *(deps)* Update rust crate serde to v1.0.216 (#141) - ([7d9827f](https://codeberg.org/ThetaDev/artifactview/commit/7d9827f9fc52aa88f19efa371e36877723328248))
- *(deps)* Update rust crate thiserror to v2.0.7 (#142) - ([1e26d04](https://codeberg.org/ThetaDev/artifactview/commit/1e26d04b068f9c2e0a65a3dbcc43f66d118a6043))
- *(deps)* Update rust crate axum-test to v16.4.1 (#143) - ([3244de4](https://codeberg.org/ThetaDev/artifactview/commit/3244de48fc2691898599ab20f2baa888db9f3c82))
- *(deps)* Update rust crate zip to v2.2.2 (#144) - ([98ba21e](https://codeberg.org/ThetaDev/artifactview/commit/98ba21e7979bfbdd06fb46da8e45f97f71dc2c99))
- *(deps)* Update rust crate proptest to v1.6.0 (#145) - ([d28f979](https://codeberg.org/ThetaDev/artifactview/commit/d28f9790b825b2af749e3fcd07718faa91c2a942))
- *(deps)* Update rust crate comrak to 0.32.0 (#146) - ([f6b0e06](https://codeberg.org/ThetaDev/artifactview/commit/f6b0e06dc1a39311c4473e0f4c3c5742a352e00f))
- *(deps)* Update rust crate thiserror to v2.0.8 (#147) - ([e87b71c](https://codeberg.org/ThetaDev/artifactview/commit/e87b71cc0df91f0ee71a88a3bd0127d4f8c74eea))
- *(deps)* Update rust crate env_logger to v0.11.6 (#148) - ([8e295a9](https://codeberg.org/ThetaDev/artifactview/commit/8e295a96decd532fdd854ee8950a68d496c1617d))
- *(deps)* Update rust crate serde_json to v1.0.134 (#149) - ([c90116c](https://codeberg.org/ThetaDev/artifactview/commit/c90116c9bac801e57062a462bd7a08a5fd6389b2))
- *(deps)* Update rust crate thiserror to v2.0.9 (#150) - ([2a2a8e0](https://codeberg.org/ThetaDev/artifactview/commit/2a2a8e0b310b4ddcf15f20b1189bf768499f6b75))
- *(deps)* Update rust crate serde to v1.0.217 (#151) - ([abe8f92](https://codeberg.org/ThetaDev/artifactview/commit/abe8f92ab8cdee926ca1ad4faf280427a9439e0e))
- *(deps)* Update rust crate quick-xml to v0.37.2 (#152) - ([d778789](https://codeberg.org/ThetaDev/artifactview/commit/d7787899593d60243c1efa6dd036fc44e9c51868))
- *(deps)* Update rust crate axum-extra to 0.10.0 (#154) - ([55621fb](https://codeberg.org/ThetaDev/artifactview/commit/55621fbbea51aed2234e43fa4b5ed61524ca1805))
- *(deps)* Update rust crate rstest to 0.24.0 (#155) - ([b3cc2b5](https://codeberg.org/ThetaDev/artifactview/commit/b3cc2b53dc032d2821748895c983dde49c56eb65))
- *(deps)* Update rust crate reqwest to v0.12.12 (#156) - ([cd73f48](https://codeberg.org/ThetaDev/artifactview/commit/cd73f4828f67d31dee356cb98a1ac45354e0e728))
- *(deps)* Update rust crate comrak to 0.33.0 (#158) - ([1ce03ca](https://codeberg.org/ThetaDev/artifactview/commit/1ce03ca19f4032d5e88c8efbe83b98c45d98247d))
- *(deps)* Update rust crate insta to v1.42.0 (#159) - ([5b138fa](https://codeberg.org/ThetaDev/artifactview/commit/5b138fae112d47a08b0940d61bd403047a8567fa))
- *(deps)* Update rust crate pin-project to v1.1.8 (#161) - ([13ee5cc](https://codeberg.org/ThetaDev/artifactview/commit/13ee5cc2456180bfdc47a2d9e3a0dfe9a2dacd7d))
- *(deps)* Update rust crate serde_json to v1.0.135 (#162) - ([8417ea3](https://codeberg.org/ThetaDev/artifactview/commit/8417ea34a0808d2ba25194021bbe38538ce52ddf))
- *(deps)* Update rust crate thiserror to v2.0.10 (#163) - ([c2ee6cd](https://codeberg.org/ThetaDev/artifactview/commit/c2ee6cd84933e7cb2167b4cd34ec47f926105e59))
- *(deps)* Update rust crate tokio to v1.43.0 (#164) - ([db790e0](https://codeberg.org/ThetaDev/artifactview/commit/db790e0811e9a67a63dc4708ca928efbcff1eb49))
- *(deps)* Update rust crate thiserror to v2.0.11 (#165) - ([db0a4fd](https://codeberg.org/ThetaDev/artifactview/commit/db0a4fd5d48842ff48a375d241139ad91796422b))
- *(deps)* Update rust crate futures-lite to v2.6.0 (#166) - ([c9a6d67](https://codeberg.org/ThetaDev/artifactview/commit/c9a6d6786f763825874520776653b311bc7bd5d8))
- *(deps)* Update rust crate serde_json to v1.0.137 (#167) - ([558ce7d](https://codeberg.org/ThetaDev/artifactview/commit/558ce7daa8fbedac507f2d6e01961896eb8daac0))
- *(deps)* Update rust crate comrak to 0.34.0 (#168) - ([a88977a](https://codeberg.org/ThetaDev/artifactview/commit/a88977af942d275422670984ddbefa53f8d61e13))
- *(deps)* Update rust crate comrak to 0.35.0 (#169) - ([4042ded](https://codeberg.org/ThetaDev/artifactview/commit/4042ded5aee3763c293ffd264cb2428eb4266845))
- *(deps)* Update rust crate insta to v1.42.1 (#170) - ([0c49fe7](https://codeberg.org/ThetaDev/artifactview/commit/0c49fe751a833ca684bfc39d19e2f1eb7ea269f5))
- *(deps)* Update rust crate axum to 0.8.0 (#157) - ([2c2893d](https://codeberg.org/ThetaDev/artifactview/commit/2c2893da218737572e3943e2b72f7cec4ca6798f))
## [v0.4.7](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.6..v0.4.7) - 2024-10-12
### 🐛 Bug Fixes
- *(deps)* Update rust crate serde_json to v1.0.121 (#29) - ([df805bf](https://codeberg.org/ThetaDev/artifactview/commit/df805bfe8394dd148ded1d4d3af901eb97593885))
- *(deps)* Update rust crate serde_json to v1.0.122 (#32) - ([db67487](https://codeberg.org/ThetaDev/artifactview/commit/db67487abdfc15fe55854fcb233e0bb876b603b3))
- *(deps)* Update rust crate regex to v1.10.6 (#33) - ([7c2a976](https://codeberg.org/ThetaDev/artifactview/commit/7c2a97666d98d4959affbb8ece93c4ba162a760d))
- *(deps)* Update rust crate serde-env to 0.2.0 (#37) - ([6b7d107](https://codeberg.org/ThetaDev/artifactview/commit/6b7d107387ff3e52e62e4ed19c64e63f8048c478))
- *(deps)* Update rust crate serde to v1.0.205 (#38) - ([f9698b5](https://codeberg.org/ThetaDev/artifactview/commit/f9698b5a7f9c7f3748d4d7aa38f7dc4c0f5f2029))
- *(deps)* Update rust crate serde to v1.0.206 (#39) - ([ed86f30](https://codeberg.org/ThetaDev/artifactview/commit/ed86f30cf4a736eeb4a3d471e81b8e7f7344b53b))
- *(deps)* Update rust crate serde_json to v1.0.124 (#40) - ([cc6a495](https://codeberg.org/ThetaDev/artifactview/commit/cc6a4959983205ae2f40d81c9a40c8514165c0bb))
- *(deps)* Update rust crate serde to v1.0.207 (#41) - ([0c2b39a](https://codeberg.org/ThetaDev/artifactview/commit/0c2b39a68a1adb567a1582f0c1b9e024fda9ed53))
- *(deps)* Update rust crate serde to v1.0.208 (#43) - ([8073e90](https://codeberg.org/ThetaDev/artifactview/commit/8073e90f685d80565db81e23769841c16c2af261))
- *(deps)* Update rust crate serde_json to v1.0.125 (#44) - ([4b3639a](https://codeberg.org/ThetaDev/artifactview/commit/4b3639aea7beed4ebc421fdfe26823be164d5c1c))
- *(deps)* Update rust crate comrak to 0.27.0 (#46) - ([3cef317](https://codeberg.org/ThetaDev/artifactview/commit/3cef3175767170824f604fcccdc912bf09745bf9))
- *(deps)* Update rust crate comrak to 0.28.0 (#47) - ([a88a3c6](https://codeberg.org/ThetaDev/artifactview/commit/a88a3c6103e776a4d10b3f7e6e9a37a2c672cfba))
- *(deps)* Update rust crate quick_cache to v0.6.6 (#50) - ([73959c0](https://codeberg.org/ThetaDev/artifactview/commit/73959c00f2c54b682c3db8640ca12319ce4ee37d))
- *(deps)* Update rust crate reqwest to v0.12.7 (#51) - ([22d5626](https://codeberg.org/ThetaDev/artifactview/commit/22d5626bf025783a127cd99faa0052778e0253b1))
- *(deps)* Update rust crate serde to v1.0.210 (#52) - ([f8c9d6f](https://codeberg.org/ThetaDev/artifactview/commit/f8c9d6f7cb475f4642f5e4f11108c4d053cc8c7e))
- *(deps)* Update rust crate serde_json to v1.0.128 (#57) - ([a48e23b](https://codeberg.org/ThetaDev/artifactview/commit/a48e23beceefc1b4c51910dc7114ab62abfd189c))
- *(deps)* Update rust crate quick_cache to v0.6.9 (#59) - ([4eb2b22](https://codeberg.org/ThetaDev/artifactview/commit/4eb2b22a8f1c2b1b28f72303d364708d04790eca))
- *(deps)* Update rust crate tower-http to 0.6.0 (#61) - ([1d03f5b](https://codeberg.org/ThetaDev/artifactview/commit/1d03f5b4b09596a68893126d8b177226b62fb38a))
- *(deps)* Update rust crate axum to v0.7.6 (#62) - ([61f65e5](https://codeberg.org/ThetaDev/artifactview/commit/61f65e54db431b3e94d21188920ae88233c44d3b))
- *(deps)* Update rust crate quick-xml to v0.36.2 (#64) - ([de4459f](https://codeberg.org/ThetaDev/artifactview/commit/de4459f646444a949c390394524f284a1944a0da))
- *(deps)* Update rust crate axum-extra to v0.9.4 (#63) - ([6619ef6](https://codeberg.org/ThetaDev/artifactview/commit/6619ef60e44832dd8839bcaf82d43707965b772a))
- *(deps)* Update rust crate thiserror to v1.0.64 (#66) - ([923f97f](https://codeberg.org/ThetaDev/artifactview/commit/923f97f8e9d0855ff97685496221a180018ae686))
- *(deps)* Update rust crate tower-http to v0.6.1 (#68) - ([ca0734d](https://codeberg.org/ThetaDev/artifactview/commit/ca0734d47072d555ba07b2f512975b9379305a58))
- *(deps)* Update rust crate secrecy to 0.10.0 (#60) - ([72d0cde](https://codeberg.org/ThetaDev/artifactview/commit/72d0cde37075220cc6a938840ad30781ecdcbaa7))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate zip to v2.1.6 (#31) - ([7e0aaa8](https://codeberg.org/ThetaDev/artifactview/commit/7e0aaa8362005b56526ed5a6114f473893a5cf46))
- *(deps)* Update rust crate flate2 to v1.0.31 (#34) - ([01e6a9c](https://codeberg.org/ThetaDev/artifactview/commit/01e6a9c8ad1f7c7dd5307a02a4a2b3c381aeacd6))
- *(deps)* Update rust crate rstest to 0.22.0 (#35) - ([b9d0a29](https://codeberg.org/ThetaDev/artifactview/commit/b9d0a29741138a4dd7b758417b003c9bddc35f3e))
- *(deps)* Update rust crate scraper to 0.20.0 (#36) - ([ca174a3](https://codeberg.org/ThetaDev/artifactview/commit/ca174a3aa21d0466930c2aa3291c2ecbed2ac31f))
- *(deps)* Update rust crate axum-test to v15.3.1 (#42) - ([24171c9](https://codeberg.org/ThetaDev/artifactview/commit/24171c9800aa52270c203e5a5fc40f520b6ac74c))
- *(deps)* Update rust crate tokio to v1.39.3 (#45) - ([63978d7](https://codeberg.org/ThetaDev/artifactview/commit/63978d79f9c3e276e59d7f3a558cec5f8f88e17e))
- *(deps)* Update rust crate flate2 to v1.0.33 (#48) - ([980e596](https://codeberg.org/ThetaDev/artifactview/commit/980e5968eaa909c9d7a72d78156d3465c4599abe))
- *(deps)* Update rust crate tokio-util to v0.7.12 (#49) - ([78179fd](https://codeberg.org/ThetaDev/artifactview/commit/78179fd73791c47cd3a60a37e704472109b50c15))
- *(deps)* Update rust crate insta to v1.40.0 (#54) - ([03597d1](https://codeberg.org/ThetaDev/artifactview/commit/03597d10e58eca8a56e4708971cac1750e7707f4))
- *(deps)* Update rust crate tokio to v1.40.0 (#55) - ([97b9610](https://codeberg.org/ThetaDev/artifactview/commit/97b9610f308c8ad0a083f6522c8b661c4b2c1e4f))
- *(deps)* Update rust crate zip to v2.2.0 (#56) - ([3f719ac](https://codeberg.org/ThetaDev/artifactview/commit/3f719ac939612722b82b8bad2744b4570ff40df7))
- *(deps)* Update rust crate once_cell to v1.20.0 (#58) - ([f8a95c8](https://codeberg.org/ThetaDev/artifactview/commit/f8a95c82e4a627d10906330327e94cd829d6f4c8))
- *(deps)* Update rust crate axum-test to v15.7.1 (#53) - ([7a92941](https://codeberg.org/ThetaDev/artifactview/commit/7a92941452b0e2f59e0ceda49a378f0ef43784cb))
- *(deps)* Update rust crate axum-test to v15.7.3 (#65) - ([82ca6dd](https://codeberg.org/ThetaDev/artifactview/commit/82ca6dd6bff9d8cb87a349cc8edb46f42a9721fc))
- *(deps)* Update rust crate axum-test to v16 (#69) - ([dbcee49](https://codeberg.org/ThetaDev/artifactview/commit/dbcee4945c0be57534bdd2d7d98d85767e0cf92c))
- *(deps)* Update rust crate flate2 to v1.0.34 (#70) - ([6f3544e](https://codeberg.org/ThetaDev/artifactview/commit/6f3544e3d8051697053cad5a751df6a1b4bc658b))
- *(deps)* Update rust crate axum to v0.7.7 (#71) - ([01c494c](https://codeberg.org/ThetaDev/artifactview/commit/01c494c2773dc67f922beed7e84aa2ff59fb575f))
- *(deps)* Update rust crate axum-test to v16.0.1 (#72) - ([2367512](https://codeberg.org/ThetaDev/artifactview/commit/23675124bdb15e01f037a6380f97f4d2921a34eb))
- *(deps)* Update rust crate once_cell to v1.20.1 (#73) - ([484f113](https://codeberg.org/ThetaDev/artifactview/commit/484f1136469f5459b1d464944621e9db450d2f6a))
- *(deps)* Update rust crate axum-test to v16.1.0 (#74) - ([2ed0cdc](https://codeberg.org/ThetaDev/artifactview/commit/2ed0cdc4a372c0a40de2ddc70a14b658a3619eec))
- *(deps)* Update rust crate regex to v1.11.0 (#75) - ([e436f77](https://codeberg.org/ThetaDev/artifactview/commit/e436f77c3e4969d83c42d45201c15f0375d90ad0))
- *(deps)* Update rust crate rstest to 0.23.0 (#76) - ([7c684eb](https://codeberg.org/ThetaDev/artifactview/commit/7c684eb5657eda84668bf3a1aadf38f1e4ac51db))
- *(deps)* Update rust crate reqwest to v0.12.8 (#77) - ([a3f028f](https://codeberg.org/ThetaDev/artifactview/commit/a3f028f2ad11d3e599bf20d1f92679bf8b8dafc4))
- *(deps)* Update rust crate async-compression to v0.4.13 (#78) - ([7cefbd4](https://codeberg.org/ThetaDev/artifactview/commit/7cefbd4a67e5636f046177f35fce14fff6300cb4))
- *(deps)* Update rust crate once_cell to v1.20.2 (#79) - ([8309901](https://codeberg.org/ThetaDev/artifactview/commit/8309901a8c658cdf948889df57b731147d77c949))
- *(deps)* Update rust crate pin-project to v1.1.6 (#80) - ([6ca7088](https://codeberg.org/ThetaDev/artifactview/commit/6ca7088b9c7ecd001df52f9cb35e86301c231bbb))
- *(deps)* Update rust crate axum-test to v16.2.0 (#81) - ([e63baec](https://codeberg.org/ThetaDev/artifactview/commit/e63baec2490e069953f63d158f9af212f154055b))
- *(deps)* Update rust crate secrecy to v0.10.3 (#82) - ([a2dc40f](https://codeberg.org/ThetaDev/artifactview/commit/a2dc40f4443cb3c3d1c7cf2b9e7178777c4b73e7))
- *(deps)* Update rust crate async-compression to v0.4.14 (#83) - ([51f098f](https://codeberg.org/ThetaDev/artifactview/commit/51f098f4ada63ddc550774a60a81eb3d55697b44))
- *(deps)* Update rust crate comrak to 0.29.0 (#84) - ([5da4074](https://codeberg.org/ThetaDev/artifactview/commit/5da4074bb9e87c9d17ed70669b6e47a3d57b1e8f))
## [v0.4.6](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.5..v0.4.6) - 2024-07-28
### 🐛 Bug Fixes
- *(deps)* Update rust crate serde_json to v1.0.120 (#14) - ([06f9c27](https://codeberg.org/ThetaDev/artifactview/commit/06f9c278a857a272580ee1c4f8e58078556accda))
- *(deps)* Update rust crate quick_cache to 0.6.0 (#15) - ([2e06266](https://codeberg.org/ThetaDev/artifactview/commit/2e0626667e5fc344df9870cd8c924b9dd60886bb))
- *(deps)* Update rust crate serde to v1.0.204 (#16) - ([1321386](https://codeberg.org/ThetaDev/artifactview/commit/13213861ba0ea30504caa50da2a99af567876e5c))
- *(deps)* Update rust crate quick-xml to 0.36.0 (#19) - ([5f94794](https://codeberg.org/ThetaDev/artifactview/commit/5f94794d24f300762da2ab162f4336508b516eda))
- *(deps)* Update rust crate async-compression to v0.4.12 (#22) - ([88c635c](https://codeberg.org/ThetaDev/artifactview/commit/88c635cbad535eb902ea54a314e8511965a792b4))
- *(deps)* Update rust crate quick-xml to v0.36.1 (#23) - ([c5c9f85](https://codeberg.org/ThetaDev/artifactview/commit/c5c9f85e4baec58dcc2001ac3a7f005c7f501557))
- *(deps)* Update rust crate quick_cache to v0.6.2 (#24) - ([a5d4973](https://codeberg.org/ThetaDev/artifactview/commit/a5d49733fd84ba37e8c258f191bf79f4affb86f9))
- *(deps)* Update rust crate thiserror to v1.0.63 (#25) - ([b67b173](https://codeberg.org/ThetaDev/artifactview/commit/b67b1730b1e9c06b4ff99774c37c71391f48f93a))
- *(deps)* Update rust crate comrak to 0.26.0 (#27) - ([642930d](https://codeberg.org/ThetaDev/artifactview/commit/642930d397efa106267bd2aff8c413ab4173a5c6))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate axum-test to v15.3.0 (#17) - ([a88f1ba](https://codeberg.org/ThetaDev/artifactview/commit/a88f1ba91c054fdb267f0edef10aacf14a909694))
- *(deps)* Lock file maintenance (#18) - ([f41a922](https://codeberg.org/ThetaDev/artifactview/commit/f41a92243c91086d5d774410b1452303fad64ccf))
- *(deps)* Update rust crate env_logger to v0.11.5 (#20) - ([c9db056](https://codeberg.org/ThetaDev/artifactview/commit/c9db0567916e500017034d6a99eb48a25a1671e0))
- *(deps)* Update rust crate scraper to v0.19.1 (#21) - ([1a5c056](https://codeberg.org/ThetaDev/artifactview/commit/1a5c056204b488e36ef95145b05a674c661a2154))
- *(deps)* Update rust crate tokio to v1.39.1 (#26) - ([057a365](https://codeberg.org/ThetaDev/artifactview/commit/057a365a0ecafe00fa84e53c736272f5db26f0f1))
- *(deps)* Update rust crate tokio to v1.39.2 (#28) - ([3ae7f88](https://codeberg.org/ThetaDev/artifactview/commit/3ae7f8813e19818098a6b67d0c6770db8a79defe))
- *(deps)* Update rust crate zip to v2 (#9) - ([a7160fa](https://codeberg.org/ThetaDev/artifactview/commit/a7160fadde6c95c6c03c90c5d2301738c8e559c2))
## [v0.4.5](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.4..v0.4.5) - 2024-07-01
### 🚀 Features
- Update PR comment format - ([197eeea](https://codeberg.org/ThetaDev/artifactview/commit/197eeea75baa8ba44d27ec46c5f552028052869b))
### 🐛 Bug Fixes
- *(deps)* Update rust crate serde_json to v1.0.118 (#5) - ([fc3b5a1](https://codeberg.org/ThetaDev/artifactview/commit/fc3b5a1530985012ff8364a8fa676626e7544eaf))
- *(deps)* Update rust crate quick-xml to 0.34.0 (#8) - ([595a9d0](https://codeberg.org/ThetaDev/artifactview/commit/595a9d0f4115faf5056653406b6d05bf671dc2b3))
- *(deps)* Update rust crate mime_guess to v2.0.5 (#10) - ([94191f8](https://codeberg.org/ThetaDev/artifactview/commit/94191f878d774bef165cca850fcdf00fde16d662))
- *(deps)* Update rust crate quick-xml to 0.35.0 (#11) - ([7ebe881](https://codeberg.org/ThetaDev/artifactview/commit/7ebe8815462f3e704a79af038b9b1850ed4677ed))
- *(deps)* Update rust crate serde_json to v1.0.119 (#12) - ([2525022](https://codeberg.org/ThetaDev/artifactview/commit/2525022df76b3c16951983c14a55fa9617114a8e))
- Swap crc and size column (#3) - ([7d2c686](https://codeberg.org/ThetaDev/artifactview/commit/7d2c68630ec6e75061c050a4c8b035edb472d150))
### 📚 Documentation
- Make example CI step compatible with GitHub+Forgejo - ([39f0019](https://codeberg.org/ThetaDev/artifactview/commit/39f0019455cc23f1b8c39b77d2aaa5af278731a9))
- Update README - ([40ae3a7](https://codeberg.org/ThetaDev/artifactview/commit/40ae3a7f557c63a0bb2abcd595218c8ec1095fe7))
### ⚙️ Miscellaneous Tasks
- *(deps)* Update rust crate proptest to v1.5.0 (#6) - ([797fc0c](https://codeberg.org/ThetaDev/artifactview/commit/797fc0c04c2a51811a24cfc431496e6e5dbf0bea))
- *(deps)* Update rust crate rstest to 0.21.0 (#7) - ([cec3aa3](https://codeberg.org/ThetaDev/artifactview/commit/cec3aa3fc02e6a871d9c221f61c3f2d8828f9f63))
- *(deps)* Lock file maintenance (#13) - ([9767167](https://codeberg.org/ThetaDev/artifactview/commit/9767167661e22775614cea7b888a19ee16c17d65))
## [v0.4.4](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.3..v0.4.4) - 2024-06-22
### 🐛 Bug Fixes
- Use forge aliases for PR comment links - ([3690b02](https://codeberg.org/ThetaDev/artifactview/commit/3690b0244cf47d0d73511f5f69f5d12abe0f1837))
## [v0.4.3](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.2..v0.4.3) - 2024-06-22
### 🐛 Bug Fixes
- 404 error on GitHub comment creation - ([d8c3ab4](https://codeberg.org/ThetaDev/artifactview/commit/d8c3ab4f36727f118b31683db87d287d9945ee14))
## [v0.4.2](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.1..v0.4.2) - 2024-06-22
### 🐛 Bug Fixes
- PR comment emoji prefix detection - ([1e36edf](https://codeberg.org/ThetaDev/artifactview/commit/1e36edf49978e8ba24a85d4663ff3ebaf9642a29))
## [v0.4.1](https://codeberg.org/ThetaDev/artifactview/compare/v0.4.0..v0.4.1) - 2024-06-22
### 🚀 Features
- Improved PR comment format, add `artifact_paths` parameter - ([6ae7520](https://codeberg.org/ThetaDev/artifactview/commit/6ae7520111469b04764115ccb5eeb3b756a4572e))
### 🐛 Bug Fixes
- Return correct status code on API errors - ([f94cdcb](https://codeberg.org/ThetaDev/artifactview/commit/f94cdcbd1fec5474145e845a8673470a174bf6f5))
## [v0.4.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.3.1..v0.4.0) - 2024-06-22
### 🚀 Features
- Create PR comments - ([d0cdbf5](https://codeberg.org/ThetaDev/artifactview/commit/d0cdbf55a3a278ce21cab11885170e9f4a6d4094))
- Add url parameter to /artifacts API - ([23b8101](https://codeberg.org/ThetaDev/artifactview/commit/23b81014266728e3db1cb200a5cf46a212baed72))
## [v0.3.1](https://codeberg.org/ThetaDev/artifactview/compare/v0.3.0..v0.3.1) - 2024-06-19
### 🐛 Bug Fixes
- Add instance domain to userscript description - ([311d3ae](https://codeberg.org/ThetaDev/artifactview/commit/311d3aedb968d1103569300d9c7f117edd96ae45))
- Do not show fallback pages for favicon - ([f1f0af6](https://codeberg.org/ThetaDev/artifactview/commit/f1f0af62647074e2cce6eb52fb188e014b248ace))
### ⚙️ Miscellaneous Tasks
- Update quick-xml to v0.32.0 - ([604d650](https://codeberg.org/ThetaDev/artifactview/commit/604d650d49180b80784948e895508aa7e0048211))
- Update dependencies - ([68ad001](https://codeberg.org/ThetaDev/artifactview/commit/68ad00170fc8b0a881bbdeb36e50374e335b3f7f))
## [v0.3.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.2.0..v0.3.0) - 2024-06-18
### 🚀 Features
- Add userscript - ([299f54f](https://codeberg.org/ThetaDev/artifactview/commit/299f54fd58b567884f76e1c36c7c1648d43fbc75))
### 🐛 Bug Fixes
- Make icon visible on light background - ([7021980](https://codeberg.org/ThetaDev/artifactview/commit/70219805d0029bb5b1e0bd08f77f444949d5b9f6))
- Redirect user to directory path when requesting index page - ([a8e173c](https://codeberg.org/ThetaDev/artifactview/commit/a8e173c8a921ab29f4e70b7abd5167fb87c6f609))
## [v0.2.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.1.0..v0.2.0) - 2024-06-14 ## [v0.2.0](https://codeberg.org/ThetaDev/artifactview/compare/v0.1.0..v0.2.0) - 2024-06-14
### 🚀 Features ### 🚀 Features

1710
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "artifactview" name = "artifactview"
version = "0.4.8" version = "0.2.0"
edition = "2021" edition = "2021"
authors = ["ThetaDev <thetadev@magenta.de>"] authors = ["ThetaDev <thetadev@magenta.de>"]
license = "MIT" license = "MIT"
@ -21,21 +21,20 @@ async_zip = { path = "crates/async_zip", features = [
"tokio-fs", "tokio-fs",
"deflate", "deflate",
] } ] }
axum = { version = "0.8.0", default-features = false, features = [ axum = { version = "0.7.5", default-features = false, features = [
"http1", "http1",
"http2", "http2",
"json", "json",
"query",
"tokio", "tokio",
"tracing", "tracing",
] } ] }
axum-extra = { version = "0.10.0", features = ["typed-header"] } axum-extra = { version = "0.9.3", features = ["typed-header"] }
comrak = { version = "0.35.0", default-features = false } comrak = { version = "0.24.1", default-features = false }
dotenvy = "0.15.7" dotenvy = "0.15.7"
envy = { path = "crates/envy" } envy = { path = "crates/envy" }
flate2 = "1.0.30" flate2 = "1.0.30"
futures-lite = "2.3.0" futures-lite = "2.3.0"
governor = "0.8.0" governor = "0.6.3"
headers = "0.4.0" headers = "0.4.0"
http = "1.1.0" http = "1.1.0"
humansize = "2.1.3" humansize = "2.1.3"
@ -46,16 +45,16 @@ once_cell = "1.19.0"
path_macro = "1.0.0" path_macro = "1.0.0"
percent-encoding = "2.3.1" percent-encoding = "2.3.1"
pin-project = "1.1.5" pin-project = "1.1.5"
quick_cache = "0.6.0" quick-xml = { version = "0.31.0", features = ["escape-html"] }
quick_cache = "0.5.1"
rand = "0.8.5" rand = "0.8.5"
regex = "1.10.4" regex = "1.10.4"
reqwest = { version = "0.12.4", default-features = false, features = [ reqwest = { version = "0.12.4", default-features = false, features = [
"json", "json",
"stream", "stream",
] } ] }
secrecy = { version = "0.10.0", features = ["serde"] }
serde = { version = "1.0.203", features = ["derive"] } serde = { version = "1.0.203", features = ["derive"] }
serde-env = "0.2.0" serde-env = "0.1.1"
serde-hex = "0.1.0" serde-hex = "0.1.0"
serde_json = "1.0.117" serde_json = "1.0.117"
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
@ -66,27 +65,26 @@ syntect = { version = "5.2.0", default-features = false, features = [
"html", "html",
"regex-onig", "regex-onig",
] } ] }
thiserror = "2.0.0" 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 = { version = "1.37.0", features = ["macros", "fs", "rt-multi-thread"] }
tokio-util = { version = "0.7.11", features = ["io"] } tokio-util = { version = "0.7.11", features = ["io"] }
tower-http = { version = "0.6.0", features = ["trace", "set-header"] } tower-http = { version = "0.5.2", features = ["trace", "set-header"] }
tracing = "0.1.40" tracing = "0.1.40"
tracing-subscriber = "0.3.18" tracing-subscriber = "0.3.18"
url = "2.5.0" url = "2.5.0"
yarte = { version = "0.15.7", features = ["json"] } yarte = "0.15.7"
[build-dependencies] [build-dependencies]
yarte_helpers = "0.15.8" yarte_helpers = "0.15.8"
[dev-dependencies] [dev-dependencies]
axum-test = "17.0.0" axum-test = "15.0.1"
flate2 = "1.0.30" flate2 = "1.0.30"
httpdate = "1.0.3" httpdate = "1.0.3"
insta = { version = "1.39.0", features = ["json"] } insta = { version = "1.39.0", features = ["json"] }
proptest = "1.4.0" proptest = "1.4.0"
rstest = { version = "0.24.0", default-features = false } rstest = { version = "0.20.0", default-features = false }
scraper = "0.22.0" scraper = "0.19.0"
temp_testdir = "0.2.3" temp_testdir = "0.2.3"
[workspace] [workspace]

View file

@ -1,5 +1,5 @@
test: test:
cargo nextest run --no-fail-fast cargo test
compress-res: compress-res:
cd resources && zopfli *.css cd resources && zopfli *.css
@ -25,7 +25,7 @@ release:
eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'" eval "git-cliff $CLIFF_ARGS --output '$CHANGELOG'"
fi fi
git add . git add "$CHANGELOG"
git commit -m "chore(release): release $CRATE v$VERSION" git commit -m "chore(release): release $CRATE v$VERSION"
awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG" awk 'BEGIN{RS="(^|\n)## [^\n]+\n*"} NR==2 { print }' "$CHANGELOG" | git tag -as -F - --cleanup whitespace "$TAG"

253
README.md
View file

@ -1,29 +1,23 @@
# Artifactview # 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 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 [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. 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 Artifactview is a small web application that fetches these CI artifacts and displays
artifacts and serves their contents. their contents.
It is a valuable tool in open source software development: you can quickly look at test It offers full support for single page applications and custom 404 error pages.
reports or coverage data or showcase your single page web applications to your Single-page applications require a file named `200.html` placed in the root directory,
teammates. 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 ![Artifact file listing](resources/screenshotFiles.png)
- 📂 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 the client does not support gzip
- 🔗 Automatically creates pull request comments with links to all build artifacts
## How to use ## How to use
@ -33,153 +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 Artifactview will show you a selection page where you will be able to choose the
artifact you want to browse. 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
automatically create a comment with links to the artifacts under every pull request.
![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: |
if [[ "$GITEA_ACTIONS" == "true" ]]; then RUN_NUMBER="$GITHUB_RUN_NUMBER"; else RUN_NUMBER="$GITHUB_RUN_ID"; fi
curl -SsL --fail-with-body -w "\n" -X POST https://av.thetadev.de/.well-known/api/prComment -H "Content-Type: application/json" \
--data '{"url": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$RUN_NUMBER"'", "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=<RUN_URL>`
`GET <HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.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 <HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.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 <HOST>--<USER>--<REPO>--<RUN>-<ARTIFACT>.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<br />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.<br />Example: `{"Hello": "🏠 Hello World ;-)"}` |
| `artifact_paths` (map) | Set custom paths for your artifacts if you want the links to point to a specific file (e.g. a test report).<br />Example: `{"Test": "/junit.xml?viewer=1"}` |
**Response**
```json
{ "status": 200, "msg": "created comment #2183634497" }
```
## Setup ## Setup
You can run artifactview using the docker image provided under You can run artifactview using the docker image provided under
@ -223,56 +70,26 @@ networks:
Artifactview is configured using environment variables. Artifactview is configured using environment variables.
Note that some variables contain lists and maps of values. Lists need to have their | Variable | Default | Description |
values separated with semicolons. Maps use an arrow `=>` between key and value, with | ------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
pairs separated by semicolons. | `PORT` | 3000 | HTTP port |
| `CACHE_DIR` | /tmp/artifactview | Temporary directory where to store the artifacts |
Example list: `foo;bar`, example map: `foo=>f1;bar=>b1` | `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 |
| Variable | Default | Description | | `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 |
| `PORT` | 3000 | HTTP port | | `MAX_FILE_SIZE` | 100000000 (100 MB) | Maximum contained file size to be served |
| `CACHE_DIR` | /tmp/artifactview | Temporary directory where to store the artifacts | | `MAX_FILE_COUNT` | 10000 | Maximum amount of files within a zip file |
| `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" | | `MAX_AGE_H` | 12 | Maximum age in hours after which cached artifacts are deleted |
| `RUST_LOG` | info | Logging level | | `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) |
| `NO_HTTPS` | false | Set to True if the website is served without HTTPS (used if testing artifactview without an ) | | `GITHUB_TOKEN` | - | GitHub API token for downloading artifacts. Using a fine-grained token with public read permissions is recommended |
| `MAX_ARTIFACT_SIZE` | 100000000 (100 MB) | Maximum size of the artifact zip file to be downloaded | | `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. |
| `MAX_FILE_SIZE` | 100000000 (100 MB) | Maximum contained file size to be served | | `REAL_IP_HEADER` | - | Get the client IP address from a HTTP request header<br />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.<br />For most proxies this header is `x-forwarded-for`. |
| `MAX_FILE_COUNT` | 10000 | Maximum amount of files within a zip file | | `LIMIT_ARTIFACTS_PER_MIN` | 5 | Limit the amount of downloaded artifacts per IP address and minute |
| `MAX_AGE_H` | 12 | Maximum age in hours after which cached artifacts are deleted | | `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)<br />Example: `github.com/evil-corp/world-destruction;codeberg.org/blackhat;example.org` |
| `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) | | `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_BLACLIST`. |
| `GITHUB_TOKEN` | - | GitHub API token for downloading artifacts and creating PR comments. Using a fine-grained token with public read permissions is recommended | | `SITE_ALIASES` | - | Aliases for sites to make URLs shorter<br />Example: `gh => github.com;cb => codeberg.org` |
| `FORGEJO_TOKENS` | - | Forgejo API tokens for creating PR comments<br />Example: `codeberg.org=>fc010f65348468d05e570806275528c936ce93a4` | | `VIEWER_MAX_SIZE` | 500000 | Maximum file size to be displayed using the viewer |
| `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<br />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.<br />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)<br />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<br />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.
If you are not using the `prComment` feature, you can use a fine-grained access token
with the "Public repositories (read-only)" permission. If you want to create pull
request comments, you have to use a classic token with the "public_repo" scope enabled
(the fine-grained tokens did not work in my test).
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 ## Technical details
@ -286,8 +103,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 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 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 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 application easier to host, but it would not be possible to simply preview a
web project. React/Vue/Svelte web project.
Since domains only allow letters, numbers and dashes but repository names allow dots and 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 underscores, these escape sequences are used to access repositories with special
@ -319,7 +136,7 @@ website (like `.well-known/acme-challenge` for issuing TLS certificates), Artifa
will serve no files from the `.well-known` folder. will serve no files from the `.well-known` folder.
There is a configurable limit for both the maximum downloaded artifact size and the 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 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 protect the server againt denial-of-service attacks like overfilling the server drive or
or uploading zip bombs. uploading zip bombs.

View file

@ -37,7 +37,7 @@ rustdoc-args = ["--cfg", "docsrs"]
crc32fast = "1" crc32fast = "1"
futures-lite = { version = "2.1.0", default-features = false, features = ["std"] } futures-lite = { version = "2.1.0", default-features = false, features = ["std"] }
pin-project = "1" pin-project = "1"
thiserror = "2" thiserror = "1"
async-compression = { version = "0.4.2", default-features = false, features = ["futures-io"], optional = true } async-compression = { version = "0.4.2", default-features = false, features = ["futures-io"], optional = true }
chrono = { version = "0.4", default-features = false, features = ["clock"], optional = true } chrono = { version = "0.4", default-features = false, features = ["clock"], optional = true }
@ -49,7 +49,7 @@ tokio-util = { version = "0.7", features = ["compat"], optional = true }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] } tokio-util = { version = "0.7", features = ["compat"] }
env_logger = "0.11.2" env_logger = "0.11.2"
zip = "2.2.2" zip = "0.6.3"
# shared across multiple examples # shared across multiple examples
# anyhow = "1" # anyhow = "1"

View file

@ -51,7 +51,7 @@ where
} }
} }
impl<R, E> AsyncRead for ZipEntryReader<'_, R, E> impl<'a, R, E> AsyncRead for ZipEntryReader<'a, R, E>
where where
R: AsyncBufRead + Unpin, R: AsyncBufRead + Unpin,
{ {
@ -60,7 +60,7 @@ where
} }
} }
impl<R, E> ZipEntryReader<'_, R, E> impl<'a, R, E> ZipEntryReader<'a, R, E>
where where
R: AsyncBufRead + Unpin, R: AsyncBufRead + Unpin,
{ {
@ -118,7 +118,7 @@ enum OwnedEntry<'a> {
Borrow(&'a ZipEntry), Borrow(&'a ZipEntry),
} }
impl OwnedEntry<'_> { impl<'a> OwnedEntry<'a> {
pub fn entry(&self) -> &'_ ZipEntry { pub fn entry(&self) -> &'_ ZipEntry {
match self { match self {
OwnedEntry::Owned(entry) => entry, OwnedEntry::Owned(entry) => entry,

View file

@ -17,7 +17,7 @@ pub(crate) enum OwnedReader<'a, R> {
Borrow(#[pin] &'a mut R), Borrow(#[pin] &'a mut R),
} }
impl<R> OwnedReader<'_, R> impl<'a, R> OwnedReader<'a, R>
where where
R: AsyncBufRead + Unpin, R: AsyncBufRead + Unpin,
{ {
@ -30,7 +30,7 @@ where
} }
} }
impl<R> AsyncBufRead for OwnedReader<'_, R> impl<'a, R> AsyncBufRead for OwnedReader<'a, R>
where where
R: AsyncBufRead + Unpin, R: AsyncBufRead + Unpin,
{ {
@ -49,7 +49,7 @@ where
} }
} }
impl<R> AsyncRead for OwnedReader<'_, R> impl<'a, R> AsyncRead for OwnedReader<'a, R>
where where
R: AsyncBufRead + Unpin, R: AsyncBufRead + Unpin,
{ {

View file

@ -64,7 +64,7 @@ impl<'b, W: AsyncWrite + Unpin> CompressedAsyncWriter<'b, W> {
} }
} }
impl<W: AsyncWrite + Unpin> AsyncWrite for CompressedAsyncWriter<'_, W> { impl<'b, W: AsyncWrite + Unpin> AsyncWrite for CompressedAsyncWriter<'b, W> {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context, buf: &[u8]) -> Poll<std::result::Result<usize, Error>> { fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context, buf: &[u8]) -> Poll<std::result::Result<usize, Error>> {
match *self { match *self {
CompressedAsyncWriter::Stored(ref mut inner) => Pin::new(inner).poll_write(cx, buf), CompressedAsyncWriter::Stored(ref mut inner) => Pin::new(inner).poll_write(cx, buf),

View file

@ -251,7 +251,7 @@ impl<'b, W: AsyncWrite + Unpin> EntryStreamWriter<'b, W> {
} }
} }
impl<W: AsyncWrite + Unpin> AsyncWrite for EntryStreamWriter<'_, W> { impl<'a, W: AsyncWrite + Unpin> AsyncWrite for EntryStreamWriter<'a, W> {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context, buf: &[u8]) -> Poll<std::result::Result<usize, Error>> { fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context, buf: &[u8]) -> Poll<std::result::Result<usize, Error>> {
let poll = Pin::new(&mut self.writer).poll_write(cx, buf); let poll = Pin::new(&mut self.writer).poll_write(cx, buf);

View file

@ -70,7 +70,7 @@ fn generate_zip64many_zip() -> std::path::PathBuf {
let zip_file = std::fs::File::create(&path).unwrap(); let zip_file = std::fs::File::create(&path).unwrap();
let mut zip = zip::ZipWriter::new(zip_file); let mut zip = zip::ZipWriter::new(zip_file);
let options = FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored); let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored);
for i in 0..2_u32.pow(16) + 1 { for i in 0..2_u32.pow(16) + 1 {
zip.start_file(format!("{i}.txt"), options).unwrap(); zip.start_file(format!("{i}.txt"), options).unwrap();

View file

@ -36,7 +36,7 @@ async fn test_write_zip64_file() {
let cursor = std::io::Cursor::new(buffer); let cursor = std::io::Cursor::new(buffer);
let mut zip = zip::read::ZipArchive::new(cursor).unwrap(); let mut zip = zip::read::ZipArchive::new(cursor).unwrap();
let mut file1 = zip.by_name("file1").unwrap(); let mut file1 = zip.by_name("file1").unwrap();
assert_eq!(file1.extra_data(), Some(&[] as &[u8])); assert_eq!(file1.extra_data(), &[] as &[u8]);
let mut buffer = Vec::new(); let mut buffer = Vec::new();
file1.read_to_end(&mut buffer).unwrap(); file1.read_to_end(&mut buffer).unwrap();
assert_eq!(buffer.as_slice(), &[0, 0, 0, 0]); assert_eq!(buffer.as_slice(), &[0, 0, 0, 0]);

View file

@ -77,7 +77,7 @@ where
struct Val(String, String); struct Val(String, String);
impl IntoDeserializer<'_, Error> for Val { impl<'de> IntoDeserializer<'de, Error> for Val {
type Deserializer = Self; type Deserializer = Self;
fn into_deserializer(self) -> Self::Deserializer { fn into_deserializer(self) -> Self::Deserializer {
@ -87,7 +87,7 @@ impl IntoDeserializer<'_, Error> for Val {
struct VarName(String); struct VarName(String);
impl IntoDeserializer<'_, Error> for VarName { impl<'de> IntoDeserializer<'de, Error> for VarName {
type Deserializer = Self; type Deserializer = Self;
fn into_deserializer(self) -> Self::Deserializer { fn into_deserializer(self) -> Self::Deserializer {
@ -248,7 +248,7 @@ struct Deserializer<'de, Iter: Iterator<Item = (String, String)>> {
inner: MapDeserializer<'de, Vars<Iter>, Error>, inner: MapDeserializer<'de, Vars<Iter>, Error>,
} }
impl<Iter: Iterator<Item = (String, String)>> Deserializer<'_, Iter> { impl<'de, Iter: Iterator<Item = (String, String)>> Deserializer<'de, Iter> {
fn new(vars: Iter) -> Self { fn new(vars: Iter) -> Self {
Deserializer { Deserializer {
inner: MapDeserializer::new(Vars(vars)), inner: MapDeserializer::new(Vars(vars)),
@ -308,7 +308,7 @@ where
/// These types are created with with the [prefixed](fn.prefixed.html) module function /// These types are created with with the [prefixed](fn.prefixed.html) module function
pub struct Prefixed<'a>(Cow<'a, str>); pub struct Prefixed<'a>(Cow<'a, str>);
impl Prefixed<'_> { impl<'a> Prefixed<'a> {
/// Deserializes a type based on prefixed env variables /// Deserializes a type based on prefixed env variables
pub fn from_env<T>(&self) -> Result<T> pub fn from_env<T>(&self) -> Result<T>
where where
@ -390,7 +390,7 @@ impl<'a> SplitEscaped<'a> {
} }
} }
impl Iterator for SplitEscaped<'_> { impl<'a> Iterator for SplitEscaped<'a> {
type Item = String; type Item = String;
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {

View file

@ -7,8 +7,8 @@ license = "BSD-2-Clause"
repository = "https://github.com/borisfaure/junit-parser" repository = "https://github.com/borisfaure/junit-parser"
[dependencies] [dependencies]
quick-xml = { version = "0.37.0", features = ["escape-html"] } quick-xml = { version = "0.31.0", features = ["escape-html"] }
thiserror = "2.0.0" thiserror = "1.0.61"
time = { version = "0.3.36", features = ["parsing", "serde-well-known"] } time = { version = "0.3.36", features = ["parsing", "serde-well-known"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View file

@ -1,7 +1,6 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::io::BufRead; use std::io::BufRead;
use quick_xml::errors::IllFormedError;
use quick_xml::escape::unescape; use quick_xml::escape::unescape;
use quick_xml::events::BytesStart as XMLBytesStart; use quick_xml::events::BytesStart as XMLBytesStart;
use quick_xml::events::Event as XMLEvent; use quick_xml::events::Event as XMLEvent;
@ -157,10 +156,7 @@ impl TestSuites {
ts.suites.push(TestSuite::new_empty(e)?); ts.suites.push(TestSuite::new_empty(e)?);
} }
Ok(XMLEvent::Eof) => { Ok(XMLEvent::Eof) => {
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag( return Err(XMLError::UnexpectedEof("testsuites".to_string()).into())
"testsuites".to_string(),
))
.into());
} }
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
_ => (), _ => (),
@ -216,10 +212,7 @@ impl TestSuite {
ts.cases.push(TestCase::new_empty(e)?); ts.cases.push(TestCase::new_empty(e)?);
} }
Ok(XMLEvent::Eof) => { Ok(XMLEvent::Eof) => {
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag( return Err(XMLError::UnexpectedEof("testsuite".to_string()).into())
"testsuite".to_string(),
))
.into());
} }
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
_ => (), _ => (),
@ -318,12 +311,7 @@ impl TestCase {
tc.status = TestStatus::Flaky; tc.status = TestStatus::Flaky;
tc.retries.push(Retry::from_reader(e, r)?); tc.retries.push(Retry::from_reader(e, r)?);
} }
XMLEvent::Eof => { XMLEvent::Eof => return Err(XMLError::UnexpectedEof("testcase".to_string()).into()),
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag(
"testcase".to_string(),
))
.into());
}
_ => (), _ => (),
} }
} }
@ -404,10 +392,7 @@ impl Message {
msg.text += e.unescape()?.trim(); msg.text += e.unescape()?.trim();
} }
Ok(XMLEvent::Eof) => { Ok(XMLEvent::Eof) => {
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag( return Err(XMLError::UnexpectedEof("failure".to_string()).into())
String::from_utf8(name.0.to_vec()).unwrap_or_default(),
))
.into());
} }
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
_ => (), _ => (),
@ -459,10 +444,7 @@ impl Retry {
rt.system_err = parse_system(e, r)?; rt.system_err = parse_system(e, r)?;
} }
Ok(XMLEvent::Eof) => { Ok(XMLEvent::Eof) => {
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag( return Err(XMLError::UnexpectedEof("failure".to_string()).into())
String::from_utf8(name.0.to_vec()).unwrap_or_default(),
))
.into());
} }
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
_ => (), _ => (),
@ -518,10 +500,7 @@ fn parse_system<B: BufRead>(
res = Some(e.unescape()?.to_string()); res = Some(e.unescape()?.to_string());
} }
Ok(XMLEvent::Eof) => { Ok(XMLEvent::Eof) => {
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag( return Err(XMLError::UnexpectedEof(format!("{:?}", orig.name())).into());
String::from_utf8(orig.name().0.to_vec()).unwrap_or_default(),
))
.into());
} }
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
_ => (), _ => (),
@ -578,10 +557,7 @@ pub fn from_reader<B: BufRead>(reader: B) -> Result<TestSuites, Error> {
return Ok(suites); return Ok(suites);
} }
Ok(XMLEvent::Eof) => { Ok(XMLEvent::Eof) => {
return Err(XMLError::IllFormed(IllFormedError::MissingEndTag( return Err(XMLError::UnexpectedEof("testsuites".to_string()).into())
"testsuites".to_string(),
))
.into());
} }
Err(err) => return Err(err.into()), Err(err) => return Err(err.into()),
_ => (), _ => (),

View file

@ -1,14 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:best-practices",
":semanticCommitTypeAll(chore)",
":preserveSemverRanges"
],
"automerge": true,
"automergeStrategy": "squash",
"osvVulnerabilityAlerts": true,
"labels": ["dependency-upgrade"],
"enabledManagers": ["cargo"],
"prHourlyLimit": 5
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -172,6 +172,7 @@ p {
width: 100%; width: 100%;
} }
.center { .center {
width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;

Binary file not shown.

View file

@ -1,25 +1,15 @@
use std::{ use std::{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; use async_zip::tokio::read::ZipEntryReader;
use axum::{ use axum::{
body::Body, body::Body,
extract::{Query as XQuery, Request, State}, extract::{Host, Request, State},
http::{Response, Uri}, http::{Response, Uri},
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
routing::{any, get, post}, routing::{any, get},
Json, RequestExt, Router, Router,
}; };
use axum_extra::extract::Host;
use futures_lite::AsyncReadExt as LiteAsyncReadExt; use futures_lite::AsyncReadExt as LiteAsyncReadExt;
use governor::{Quota, RateLimiter};
use headers::{ContentType, HeaderMapExt}; use headers::{ContentType, HeaderMapExt};
use http::{HeaderMap, StatusCode}; use http::{HeaderMap, StatusCode};
use serde::Deserialize; use serde::Deserialize;
@ -37,7 +27,7 @@ use tower_http::{
}; };
use crate::{ use crate::{
artifact_api::{Artifact, ArtifactApi, WorkflowRun}, artifact_api::ArtifactApi,
cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile}, cache::{Cache, CacheEntry, GetFileResult, GetFileResultFile},
config::Config, config::Config,
error::Error, error::Error,
@ -60,12 +50,6 @@ struct AppInner {
cache: Cache, cache: Cache,
api: ArtifactApi, api: ArtifactApi,
viewers: Viewers, viewers: Viewers,
lim_pr_comment: Option<
governor::DefaultKeyedRateLimiter<
IpAddr,
governor::middleware::NoOpMiddleware<governor::clock::QuantaInstant>,
>,
>,
} }
impl Default for App { impl Default for App {
@ -79,39 +63,6 @@ struct FileQparams {
viewer: Option<String>, viewer: Option<String>,
} }
#[derive(Deserialize)]
struct UrlQuery {
url: Option<String>,
}
#[derive(Deserialize)]
struct PrCommentReq {
/// Workflow run URL
url: String,
/// Pull request number
pr: u64,
/// If set to true, it will delete the previous PR comment and create a new one instead
/// of updating it
#[serde(default)]
recreate: bool,
/// Comment title
///
/// Default: "👁️ Latest build artifacts"
title: Option<String>,
/// Map of custom artifact titles
#[serde(default)]
artifact_titles: HashMap<String, String>,
/// Map of custom artifact paths
///
/// If you want the artifact links in the comment to point to a specific file in the
/// artifact (e.g. a test report), you can set a custom path for the artifact
#[serde(default)]
artifact_paths: HashMap<String, String>,
}
const DATE_FORMAT: &[time::format_description::FormatItem] =
time::macros::format_description!("[day].[month].[year] [hour]:[minute]:[second]");
const FAVICON_PATH: &str = "/favicon.ico"; const FAVICON_PATH: &str = "/favicon.ico";
pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
@ -173,10 +124,9 @@ impl App {
.route("/.well-known/api/artifacts", get(Self::get_artifacts)) .route("/.well-known/api/artifacts", get(Self::get_artifacts))
.route("/.well-known/api/artifact", get(Self::get_artifact)) .route("/.well-known/api/artifact", get(Self::get_artifact))
.route("/.well-known/api/files", get(Self::get_files)) .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 // Prevent access to the .well-known folder since it enables abuse
// (e.g. SSL certificate registration by an attacker) // (e.g. SSL certificate registration by an attacker)
.route("/.well-known/{*path}", any(|| async { Error::Inaccessible })) .route("/.well-known/*path", any(|| async { Error::Inaccessible }))
// Serve artifact pages // Serve artifact pages
.route("/", get(Self::get_page)) .route("/", get(Self::get_page))
.fallback(get(Self::get_page)) .fallback(get(Self::get_page))
@ -226,73 +176,68 @@ impl App {
} }
match entry.get_file(&path, uri.query().unwrap_or_default()) { match entry.get_file(&path, uri.query().unwrap_or_default()) {
Ok(gfr) => { Ok(GetFileResult::File(res)) => {
if gfr.index() && !path.ends_with('/') { let qparams = uri
return Ok(Redirect::permanent(&format!("{path}/")).into_response()); .query()
} .and_then(|q| serde_urlencoded::from_str::<FileQparams>(q).ok())
.unwrap_or_default();
match gfr { if res.filename.is_some() {
GetFileResult::File(res) => { if let Some(viewer) = qparams.viewer {
let qparams = uri match Self::try_view_file(
.query() &state,
.and_then(|q| serde_urlencoded::from_str::<FileQparams>(q).ok()) &entry,
.unwrap_or_default(); &entry_res.zip_path,
if res.filename.is_some() { &query,
if let Some(viewer) = qparams.viewer { &res,
match Self::try_view_file( &viewer,
&state, &path,
&entry, )
&entry_res.zip_path, .await
&query, {
&res, Ok(resp) => return Ok(resp),
&viewer, Err(e) => {
&path, tracing::error!("{e}")
)
.await
{
Ok(resp) => return Ok(resp),
Err(e) => {
tracing::error!("{e}")
}
}
} }
} }
Self::serve_artifact_file(&state, entry, &entry_res.zip_path, res, hdrs)
.await
}
GetFileResult::Listing(listing) => {
let run_url = query.forge_url();
let tmpl = templates::Listing {
main_url: state.i.cfg.main_url(),
run_url: &run_url,
artifact_name: &entry.name,
path_components: path_components(
&query,
state.i.cfg.main_url(),
&run_url,
&entry.name,
&path,
),
n_dirs: listing.n_dirs,
n_files: listing.n_files,
has_parent: listing.has_parent,
publisher: query.publisher(),
viewer_max_size: state
.i
.cfg
.load()
.viewer_max_size
.map(u32::from)
.unwrap_or(u32::MAX),
entries: listing.entries,
};
Ok(Response::builder()
.typed_header(headers::ContentType::html())
.cache()
.body(tmpl.to_string().into())?)
} }
} }
Self::serve_artifact_file(&state, entry, &entry_res.zip_path, res, hdrs).await
}
Ok(GetFileResult::Listing(listing)) => {
if !path.ends_with('/') {
return Ok(Redirect::to(&format!("{path}/")).into_response());
}
let run_url = query.forge_url();
let tmpl = templates::Listing {
main_url: state.i.cfg.main_url(),
run_url: &run_url,
artifact_name: &entry.name,
path_components: path_components(
&query,
state.i.cfg.main_url(),
&run_url,
&entry.name,
&path,
),
n_dirs: listing.n_dirs,
n_files: listing.n_files,
has_parent: listing.has_parent,
publisher: query.publisher(),
viewer_max_size: state
.i
.cfg
.load()
.viewer_max_size
.map(u32::from)
.unwrap_or(u32::MAX),
entries: listing.entries,
};
Ok(Response::builder()
.typed_header(headers::ContentType::html())
.cache()
.body(tmpl.to_string().into())?)
} }
Err(Error::NotFound(e)) => { Err(Error::NotFound(e)) => {
if path == FAVICON_PATH { if path == FAVICON_PATH {
@ -311,62 +256,17 @@ impl App {
uri: Uri, uri: Uri,
hdrs: &HeaderMap, hdrs: &HeaderMap,
) -> Result<Response<Body>, Error> { ) -> Result<Response<Body>, Error> {
if uri.path() == FAVICON_PATH {
return Self::favicon();
}
if uri.path() == STYLE_MAIN_PATH {
return Self::stylesheet(hdrs, STYLE_MAIN_BYTES, STYLE_MAIN_BYTES_GZ);
}
if uri.path() == STYLE_CONTENT_PATH {
return Self::stylesheet(hdrs, STYLE_CONTENT_BYTES, STYLE_CONTENT_BYTES_GZ);
}
if uri.path() != "/" { if uri.path() != "/" {
match uri.path() { return Err(Error::NotFound("path".into()));
FAVICON_PATH => return Self::favicon(),
STYLE_MAIN_PATH => {
return Self::stylesheet(hdrs, STYLE_MAIN_BYTES, STYLE_MAIN_BYTES_GZ)
}
STYLE_CONTENT_PATH => {
return Self::stylesheet(hdrs, STYLE_CONTENT_BYTES, STYLE_CONTENT_BYTES_GZ)
}
"/artifactview.user.js" => {
let cfg = state.i.cfg.load();
let map_host = |h: &str| {
// Since GitHub uses Javascript for page changes, the userscript needs to be always loaded
if h == "github.com" {
"https://github.com/*".to_owned()
} else {
format!("https://{h}/*/runs/*")
}
};
let forge_urls = if cfg.repo_whitelist.is_empty() {
cfg.suggested_sites
.iter()
.map(|itm| map_host(itm))
.collect::<Vec<_>>()
} else {
cfg.repo_whitelist
.hosts()
.iter()
.map(|h| map_host(h))
.collect::<Vec<_>>()
};
let aliases = cfg
.site_aliases
.iter()
.map(|(k, v)| (v.as_str(), k.as_str()))
.collect::<BTreeMap<_, _>>();
let tmpl = templates::Userscript {
main_url: state.i.cfg.main_url(),
root_domain: &cfg.root_domain,
no_https: cfg.no_https,
forge_urls,
aliases: &aliases,
};
return Ok(Response::builder()
.typed_header(headers::ContentType::from(
mime::APPLICATION_JAVASCRIPT_UTF_8,
))
.cache()
.body(tmpl.to_string().into())?);
}
_ => return Err(Error::NotFound("path".into())),
}
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -379,9 +279,8 @@ impl App {
.query() .query()
.and_then(|q| serde_urlencoded::from_str::<Params>(q).ok()) .and_then(|q| serde_urlencoded::from_str::<Params>(q).ok())
{ {
let query = let query = RunQuery::from_forge_url(&params.url, &state.i.cfg.load().site_aliases)?;
RunQuery::from_forge_url_alias(&params.url, &state.i.cfg.load().site_aliases)?; let artifacts = state.i.api.list(&query).await?;
let artifacts = state.i.api.list(&query, true).await?;
if artifacts.is_empty() { if artifacts.is_empty() {
Err(Error::NotFound("artifacts".into())) Err(Error::NotFound("artifacts".into()))
@ -419,7 +318,6 @@ impl App {
.body( .body(
templates::Index { templates::Index {
main_url: state.i.cfg.main_url(), main_url: state.i.cfg.main_url(),
example_site: state.i.cfg.example_site(),
} }
.to_string() .to_string()
.into(), .into(),
@ -594,7 +492,7 @@ impl App {
.typed_header(headers::ContentLength(content_length)) .typed_header(headers::ContentLength(content_length))
.typed_header( .typed_header(
headers::ContentRange::bytes(range, total_len) 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( .body(Body::from_stream(ReaderStream::new(
bufreader.take(content_length), bufreader.take(content_length),
@ -611,18 +509,11 @@ impl App {
async fn get_artifacts( async fn get_artifacts(
State(state): State<AppState>, State(state): State<AppState>,
Host(host): Host, Host(host): Host,
url_query: XQuery<UrlQuery>,
) -> Result<Response<Body>, ErrorJson> { ) -> Result<Response<Body>, ErrorJson> {
let query = match &url_query.url { let subdomain = util::get_subdomain(&host, &state.i.cfg.load().root_domain)?;
Some(url) => RunQuery::from_forge_url(url)?, let query = ArtifactQuery::from_subdomain(subdomain, &state.i.cfg.load().site_aliases)?;
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)?; 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)?) Ok(Response::builder().cache().json(&artifacts)?)
} }
@ -659,84 +550,6 @@ impl App {
.json(&files)?) .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<AppState>,
request: Request,
) -> Result<ErrorJson, ErrorJson> {
let ip = util::get_ip_address(&request, state.i.cfg.load().real_ip_header.as_deref())?;
let req = request
.extract::<Json<PrCommentReq>, _>()
.await
.map_err(|e| Error::BadRequest(e.body_text().into()))?;
let query = RunQuery::from_forge_url_alias(&req.url, &state.i.cfg.load().site_aliases)?;
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 run is not running".into()).into());
}
if let Some(pr_number) = run.pr_number {
if pr_number != req.pr {
return Err(Error::BadRequest(
format!(
"workflow run 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(PrCommentTextParams {
query: &query,
old_comment: old_comment.as_ref().map(|c| c.body.as_str()),
run: &run,
artifacts: &artifacts,
title: req.title.as_deref(),
artifact_titles: &req.artifact_titles,
artifact_paths: &req.artifact_paths,
cfg: &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<Response<Body>, Error> { fn favicon() -> Result<Response<Body>, Error> {
Ok(Response::builder() Ok(Response::builder()
.typed_header(headers::ContentType::from_str("image/x-icon").unwrap()) .typed_header(headers::ContentType::from_str("image/x-icon").unwrap())
@ -754,7 +567,7 @@ impl App {
.typed_header(headers::ContentType::from(mime::TEXT_CSS)) .typed_header(headers::ContentType::from(mime::TEXT_CSS))
.cache_immutable(); .cache_immutable();
// Don't serve compressed stylesheets in debug mode to allow live changes // Dont serve compressed stylesheets in debug mode to allow live changes
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
if util::accepts_gzip(hdrs) { if util::accepts_gzip(hdrs) {
return Ok(resp return Ok(resp
@ -776,14 +589,10 @@ impl AppState {
let api = ArtifactApi::new(cfg.clone()); let api = ArtifactApi::new(cfg.clone());
Self { Self {
i: Arc::new(AppInner { i: Arc::new(AppInner {
cfg,
cache, cache,
api, api,
viewers: Viewers::new(), viewers: Viewers::new(),
lim_pr_comment: cfg
.load()
.limit_artifacts_per_min
.map(|lim| RateLimiter::keyed(Quota::per_minute(lim))),
cfg,
}), }),
} }
} }
@ -827,202 +636,3 @@ fn path_components(
} }
path_components path_components
} }
struct PrCommentTextParams<'a> {
query: &'a RunQuery,
old_comment: Option<&'a str>,
run: &'a WorkflowRun,
artifacts: &'a [Artifact],
title: Option<&'a str>,
artifact_titles: &'a HashMap<String, String>,
artifact_paths: &'a HashMap<String, String>,
cfg: &'a Config,
}
/// Build pull request comment text
#[allow(clippy::assigning_clones)]
fn pr_comment_text(p: PrCommentTextParams) -> String {
let query = p.query;
let mut content = "### ".to_owned();
let mut prevln = "- ".to_owned();
let a_opts = r#"target="_blank" rel="noopener noreferrer""#;
let date_started = p
.run
.date_started
.and_then(|d| d.to_offset(time::UtcOffset::UTC).format(&DATE_FORMAT).ok());
let mut prev_builds = None;
let mut np_content = None;
if let Some(old_comment) = p.old_comment {
prev_builds = util::extract_delim(old_comment, "</summary>", "<!--NEXT_PREV");
np_content = util::extract_delim(old_comment, "<!--NEXT_PREV", "-->");
}
let write_commit = |s: &mut String, sha: &str| {
_ = write!(
s,
"[{}](https://{}/{}/{}/commit/{})",
&sha[..10],
query.host,
query.user,
query.repo,
sha
);
};
let write_link_icon = |s: &mut String, title: &str, href: &str| {
let (title_pfx, title) = util::split_icon_prefix(title);
_ = write!(s, r#"{title_pfx}<a href="{href}" {a_opts}>{title}</a>"#,);
};
// Comment title
let run_url = query.forge_url();
let artifacts_url = format!("{}/?url={}", p.cfg.main_url(), run_url);
write_link_icon(
&mut content,
p.title.unwrap_or("Latest build artifacts"),
&artifacts_url,
);
_ = write!(&mut content, "\n\n Run [#{}]({}) · ", query.run, run_url);
write_commit(&mut content, &p.run.head_sha);
if let Some(date_started) = &date_started {
_ = write!(&mut content, " · {} UTC", date_started);
}
_ = content.write_str("\n\n");
// Previous run line
_ = write!(&mut prevln, "[#{}]({}) [", query.run, run_url);
write_commit(&mut prevln, &p.run.head_sha);
_ = write!(
&mut prevln,
"] <a href=\"{artifacts_url}\" {a_opts}>Artifacts</a>: "
);
for a in p.artifacts.iter().filter(|a| !a.expired) {
let mut url = p
.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;
}
if let Some(path) = p.artifact_paths.get(&a.name) {
url += path;
}
write_link_icon(
&mut content,
p.artifact_titles.get(&a.name).unwrap_or(&a.name),
&url,
);
_ = content.write_str("<br>\n");
_ = write!(
&mut prevln,
r#" <a href="{url}" {a_opts}>`{}`</a>,"#,
a.name
);
}
if prevln.ends_with(',') {
prevln.pop();
}
if let Some(date_started) = &date_started {
_ = write!(&mut prevln, " ({} UTC)", date_started);
}
if np_content.is_some() || prev_builds.is_some() {
_ = write!(
&mut content,
"<details>\n<summary>Previous builds</summary>\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, "<!--NEXT_PREV {prevln} -->\n</details>");
} else {
_ = writeln!(&mut content, "<!--NEXT_PREV {prevln} -->");
}
_ = write!(&mut content, "\n<sup>generated by [Artifactview {VERSION}](https://codeberg.org/ThetaDev/artifactview)</sup>");
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 mut artifact_paths = HashMap::new();
artifact_paths.insert("Test".to_owned(), "/junit.xml?viewer=1".to_owned());
let cfg = Config::default();
let footer = format!("<sup>generated by [Artifactview {VERSION}](https://codeberg.org/ThetaDev/artifactview)</sup>");
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(PrCommentTextParams {
query: &query,
old_comment: old_comment.as_deref(),
run: &run,
artifacts: &artifacts,
title: None,
artifact_titles: &artifact_titles,
artifact_paths: &artifact_paths,
cfg: &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);
}
}
}

View file

@ -1,15 +1,12 @@
//! API-Client to fetch CI artifacts from Github and Forgejo //! API-Client to fetch CI artifacts from Github and Forgejo
use std::path::Path; use std::path::Path;
use futures_lite::StreamExt; use futures_lite::StreamExt;
use http::{header, Method}; use http::header;
use once_cell::sync::Lazy;
use quick_cache::sync::Cache as QuickCache; use quick_cache::sync::Cache as QuickCache;
use regex::Regex;
use reqwest::{Client, ClientBuilder, IntoUrl, RequestBuilder, Response, Url}; use reqwest::{Client, ClientBuilder, IntoUrl, RequestBuilder, Response, Url};
use secrecy::ExposeSecret; use serde::{Deserialize, Serialize};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use time::OffsetDateTime;
use tokio::{fs::File, io::AsyncWriteExt}; use tokio::{fs::File, io::AsyncWriteExt};
use crate::{ use crate::{
@ -22,10 +19,9 @@ pub struct ArtifactApi {
http: Client, http: Client,
cfg: Config, cfg: Config,
qc: QuickCache<String, Vec<Artifact>>, qc: QuickCache<String, Vec<Artifact>>,
user_ids: QuickCache<String, u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Artifact { pub struct Artifact {
pub id: u64, pub id: u64,
pub name: String, pub name: String,
@ -39,7 +35,7 @@ pub struct Artifact {
pub user_download_url: Option<String>, pub user_download_url: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
struct GithubArtifact { struct GithubArtifact {
id: u64, id: u64,
name: String, name: String,
@ -48,24 +44,24 @@ struct GithubArtifact {
archive_download_url: String, archive_download_url: String,
} }
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
struct ForgejoArtifact { struct ForgejoArtifact {
name: String, name: String,
size: u64, size: u64,
status: ForgejoArtifactStatus, status: ForgejoArtifactStatus,
} }
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
struct ApiError { struct ApiError {
message: String, message: String,
} }
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
struct ArtifactsWrap<T> { struct ArtifactsWrap<T> {
artifacts: Vec<T>, artifacts: Vec<T>,
} }
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum ForgejoArtifactStatus { enum ForgejoArtifactStatus {
Completed, Completed,
@ -104,154 +100,6 @@ impl ForgejoArtifact {
} }
} }
#[derive(Debug)]
pub struct WorkflowRun {
pub head_sha: String,
pub from_pr: bool,
pub pr_number: Option<u64>,
pub date_started: Option<OffsetDateTime>,
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<ForgejoWorkflowLogStep>,
}
#[derive(Debug, Deserialize)]
struct ForgejoWorkflowLogStep {
started: i64,
lines: Vec<LogMessage>,
}
#[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 = "<!-- Artifactview -->";
impl TryFrom<ForgejoWorkflowRun> for WorkflowRun {
type Error = Error;
fn try_from(value: ForgejoWorkflowRun) -> Result<Self> {
static RE_COMMIT_SHA: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"^/[\w\-\.]+/[\w\-\.]+/commit/([a-f\d]+)$"#).unwrap());
static RE_PULL_ID: Lazy<Regex> =
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<String>,
#[serde(with = "time::serde::rfc3339::option")]
run_started_at: Option<OffsetDateTime>,
}
impl From<GitHubWorkflowRun> 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 { impl ArtifactApi {
pub fn new(cfg: Config) -> Self { pub fn new(cfg: Config) -> Self {
Self { Self {
@ -264,34 +112,28 @@ impl ArtifactApi {
.build() .build()
.unwrap(), .unwrap(),
qc: QuickCache::new(cfg.load().mem_cache_size), qc: QuickCache::new(cfg.load().mem_cache_size),
user_ids: QuickCache::new(50),
cfg, cfg,
} }
} }
#[tracing::instrument(level = "error", skip_all)] pub async fn list(&self, query: &RunQuery) -> Result<Vec<Artifact>> {
pub async fn list(&self, query: &RunQuery, cached: bool) -> Result<Vec<Artifact>> {
let cache_key = query.cache_key(); let cache_key = query.cache_key();
let fut = async { self.qc
let res = if query.is_github() { .get_or_insert_async(&cache_key, async {
self.list_github(query.as_ref()).await let res = if query.is_github() {
} else { self.list_github(query.as_ref()).await
self.list_forgejo(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())) if res.as_ref().is_ok_and(|v| v.is_empty()) {
} else { Err(Error::NotFound("artifact".into()))
res } else {
} res
}; }
if cached { })
self.qc.get_or_insert_async(&cache_key, fut).await .await
} else {
fut.await
}
} }
#[tracing::instrument(level = "error", skip_all)]
pub async fn fetch(&self, query: &ArtifactQuery) -> Result<Artifact> { pub async fn fetch(&self, query: &ArtifactQuery) -> Result<Artifact> {
if query.is_github() { if query.is_github() {
self.fetch_github(query).await self.fetch_github(query).await
@ -307,7 +149,6 @@ impl ArtifactApi {
} }
} }
#[tracing::instrument(level = "error", skip_all)]
pub async fn download(&self, artifact: &Artifact, path: &Path) -> Result<()> { pub async fn download(&self, artifact: &Artifact, path: &Path) -> Result<()> {
if artifact.expired { if artifact.expired {
return Err(Error::Expired); return Err(Error::Expired);
@ -336,7 +177,7 @@ impl ArtifactApi {
let url = Url::parse(&artifact.download_url)?; let url = Url::parse(&artifact.download_url)?;
let req = if url.domain() == Some("api.github.com") { let req = if url.domain() == Some("api.github.com") {
self.get_github_any(url) self.get_github(url)
} else { } else {
self.http.get(url) self.http.get(url)
}; };
@ -371,7 +212,8 @@ impl ArtifactApi {
); );
let resp = self let resp = self
.get_forgejo(url) .http
.get(url)
.send() .send()
.await? .await?
.error_for_status()? .error_for_status()?
@ -394,8 +236,10 @@ impl ArtifactApi {
query.user, query.repo, query.run query.user, query.repo, query.run
); );
let resp = let resp = Self::handle_github_error(self.get_github(url).send().await?)
Self::send_api_req::<ArtifactsWrap<GithubArtifact>>(self.get_github(url)).await?; .await?
.json::<ArtifactsWrap<GithubArtifact>>()
.await?;
Ok(resp Ok(resp
.artifacts .artifacts
@ -410,351 +254,41 @@ impl ArtifactApi {
query.user, query.repo, query.artifact query.user, query.repo, query.artifact
); );
let artifact = Self::send_api_req::<GithubArtifact>(self.get_github(url)).await?; let artifact = Self::handle_github_error(self.get_github(url).send().await?)
.await?
.json::<GithubArtifact>()
.await?;
Ok(artifact.into_artifact(query.as_ref())) Ok(artifact.into_artifact(query.as_ref()))
} }
async fn send_api_req_empty(req: RequestBuilder) -> Result<Response> { async fn handle_github_error(resp: Response) -> Result<Response> {
let resp = req.send().await?;
if let Err(e) = resp.error_for_status_ref() { if let Err(e) = resp.error_for_status_ref() {
let status = resp.status(); let status = resp.status();
let msg = resp.json::<ApiError>().await.ok(); let msg = resp.json::<ApiError>().await.ok();
let msg_str = msg.map(|msg| msg.message).unwrap_or(e.to_string()).into(); Err(Error::HttpClient(
tracing::error!("API error: {msg_str}"); msg.map(|msg| msg.message).unwrap_or(e.to_string()).into(),
Err(Error::HttpClient(msg_str, status)) status,
))
} else { } else {
Ok(resp) Ok(resp)
} }
} }
async fn send_api_req<T: DeserializeOwned>(req: RequestBuilder) -> Result<T> { fn get_github<U: IntoUrl>(&self, url: U) -> RequestBuilder {
Ok(Self::send_api_req_empty(req).await?.json().await?)
}
fn get_github_any<U: IntoUrl>(&self, url: U) -> RequestBuilder {
let mut builder = self.http.get(url); let mut builder = self.http.get(url);
if let Some(github_token) = &self.cfg.load().github_token { if let Some(github_token) = &self.cfg.load().github_token {
builder = builder.header( builder = builder.header(header::AUTHORIZATION, format!("Bearer {github_token}"));
header::AUTHORIZATION,
format!("Bearer {}", github_token.expose_secret()),
);
} }
builder builder
} }
fn get_github<U: IntoUrl>(&self, url: U) -> RequestBuilder {
self.get_github_any(url)
.header(header::ACCEPT, GITHUB_ACCEPT)
}
/// Authorized GitHub request
fn req_github<U: IntoUrl>(&self, method: Method, url: U) -> Result<RequestBuilder> {
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<U: IntoUrl>(&self, url: U) -> RequestBuilder {
self.http
.get(url)
.header(header::ACCEPT, mime::APPLICATION_JSON.essence_str())
}
/// Authorized Forgejo request
fn req_forgejo<U: IntoUrl>(&self, method: Method, url: U) -> Result<RequestBuilder> {
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}")))
}
#[tracing::instrument(level = "error", skip_all)]
pub async fn workflow_run(&self, query: &RunQuery) -> Result<WorkflowRun> {
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<WorkflowRun> {
// 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::<ForgejoWorkflowRun>().await?.try_into()?;
Ok(run)
}
async fn workflow_run_github(&self, query: &RunQuery) -> Result<WorkflowRun> {
let run = Self::send_api_req::<GitHubWorkflowRun>(self.get_github(format!(
"https://api.github.com/repos/{}/{}/actions/runs/{}",
query.user, query.repo, query.run
)))
.await?;
Ok(run.into())
}
#[tracing::instrument(level = "error", skip_all)]
pub async fn add_comment(
&self,
query: QueryRef<'_>,
issue_id: u64,
content: &str,
old_comment_id: Option<u64>,
recreate: bool,
) -> Result<u64> {
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<u64>,
recreate: bool,
) -> Result<u64> {
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::<IdEntity>(
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<u64>,
recreate: bool,
) -> Result<u64> {
if let Some(old_comment_id) = old_comment_id {
let url = format!(
"https://api.github.com/repos/{}/{}/issues/comments/{}",
query.user, query.repo, 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::<IdEntity>(
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)
}
#[tracing::instrument(level = "error", skip_all)]
pub async fn find_comment(
&self,
query: QueryRef<'_>,
issue_id: u64,
) -> Result<Option<Comment>> {
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<Option<Comment>> {
let comments = Self::send_api_req::<Vec<Comment>>(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<Option<Comment>> {
for page in 1..=5 {
let comments = Self::send_api_req::<Vec<Comment>>(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)
}
#[tracing::instrument(level = "error", skip_all)]
pub async fn get_pr(&self, query: QueryRef<'_>, pr_id: u64) -> Result<PullRequest> {
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<u64> {
self.user_ids
.get_or_insert_async(query.host, async {
let user =
if query.is_github() {
Self::send_api_req::<IdEntity>(
self.req_github(Method::GET, "https://api.github.com/user")?,
)
.await?
} else {
Self::send_api_req::<IdEntity>(self.req_forgejo(
Method::GET,
format!("https://{}/api/v1/user", query.host),
)?)
.await?
};
Ok::<_, Error>(user.id)
})
.await
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use time::macros::datetime; use crate::{config::Config, query::ArtifactQuery};
use crate::{
config::Config,
query::{ArtifactQuery, RunQuery},
};
use super::ArtifactApi; use super::ArtifactApi;
@ -787,31 +321,4 @@ mod tests {
assert_eq!(res.id, 1440556464); assert_eq!(res.id, 1440556464);
assert_eq!(res.size, 334); 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)));
}
} }

View file

@ -72,7 +72,6 @@ pub struct GetFileResultFile {
pub file: FileEntry, pub file: FileEntry,
pub mime: Option<Mime>, pub mime: Option<Mime>,
pub status: StatusCode, pub status: StatusCode,
pub index: bool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -106,17 +105,6 @@ pub struct Size(pub u32);
#[derive(Serialize)] #[derive(Serialize)]
pub struct Crc32(#[serde(with = "SerHexOpt::<serde_hex::Strict>")] pub Option<u32>); pub struct Crc32(#[serde(with = "SerHexOpt::<serde_hex::Strict>")] pub Option<u32>);
impl GetFileResult {
/// Return true if the result represents a directory index, so the client has to be redirected to the directory path
/// if the requested path does not end with a slash (otherwise resources on the index.html may not resolve properly)
pub fn index(&self) -> bool {
match self {
GetFileResult::File(f) => f.index,
GetFileResult::Listing(_) => true,
}
}
}
impl Cache { impl Cache {
pub fn new(cfg: Config) -> Self { pub fn new(cfg: Config) -> Self {
Self { Self {
@ -166,10 +154,10 @@ impl Cache {
let metadata = tokio::fs::metadata(&zip_path).await?; let metadata = tokio::fs::metadata(&zip_path).await?;
let modified = metadata let modified = metadata
.modified() .modified()
.map_err(|_| Error::Other("no file modified time".into()))?; .map_err(|_| Error::Internal("no file modified time".into()))?;
let accessed = metadata let accessed = metadata
.accessed() .accessed()
.map_err(|_| Error::Other("no file accessed time".into()))?; .map_err(|_| Error::Internal("no file accessed time".into()))?;
if modified != entry.last_modified { if modified != entry.last_modified {
tracing::info!("cached file {zip_path:?} changed"); tracing::info!("cached file {zip_path:?} changed");
entry = Arc::new( entry = Arc::new(
@ -182,7 +170,7 @@ impl Cache {
let now = SystemTime::now(); let now = SystemTime::now();
if now if now
.duration_since(accessed) .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) > Duration::from_secs(1800)
{ {
let file = std::fs::File::open(&zip_path)?; let file = std::fs::File::open(&zip_path)?;
@ -215,10 +203,10 @@ impl Cache {
.metadata() .metadata()
.await? .await?
.accessed() .accessed()
.map_err(|_| Error::Other("no file accessed time".into()))?; .map_err(|_| Error::Internal("no file accessed time".into()))?;
if now if now
.duration_since(accessed) .duration_since(accessed)
.map_err(|e| Error::Other(e.to_string().into()))? .map_err(|e| Error::Internal(e.to_string().into()))?
> max_age > max_age
{ {
let path = entry.path(); let path = entry.path();
@ -289,7 +277,7 @@ impl CacheEntry {
name, name,
last_modified: meta last_modified: meta
.modified() .modified()
.map_err(|_| Error::Other("no file modified time".into()))?, .map_err(|_| Error::Internal("no file modified time".into()))?,
}) })
} }
@ -310,36 +298,21 @@ impl CacheEntry {
file: file.clone(), file: file.clone(),
mime: util::path_mime(path), mime: util::path_mime(path),
status: StatusCode::OK, status: StatusCode::OK,
index: false,
})); }));
} else if util::site_path_ext(path).is_none() { } else if util::site_path_ext(path).is_none() {
index_path = Some(format!("{path}/index.html").into()); index_path = Some(format!("{path}/index.html").into());
} }
if let Some(file) = index_path.and_then(|p: Cow<str>| self.files.get(p.as_ref())) { if let Some(file) = index_path
// index.html .and_then(|p: Cow<str>| self.files.get(p.as_ref()))
.or_else(|| self.files.get("200.html"))
{
// index.html or SPA entrypoint
return Ok(GetFileResult::File(GetFileResultFile { return Ok(GetFileResult::File(GetFileResultFile {
filename: None, filename: None,
file: file.clone(), file: file.clone(),
mime: Some(mime::TEXT_HTML), mime: Some(mime::TEXT_HTML),
status: StatusCode::OK, status: StatusCode::OK,
index: true,
}));
}
// Do not show fallback pages for favicon
if path == "favicon.ico" {
return Err(Error::NotFound("requested file".into()));
}
// SPA entrypoint
if let Some(file) = self.files.get("200.html") {
return Ok(GetFileResult::File(GetFileResultFile {
filename: None,
file: file.clone(),
mime: Some(mime::TEXT_HTML),
status: StatusCode::OK,
index: false,
})); }));
} }
@ -375,7 +348,6 @@ impl CacheEntry {
file: file.clone(), file: file.clone(),
mime: Some(mime::TEXT_HTML), mime: Some(mime::TEXT_HTML),
status: StatusCode::NOT_FOUND, status: StatusCode::NOT_FOUND,
index: false,
})); }));
} }

View file

@ -5,12 +5,11 @@ use std::{
sync::Arc, sync::Arc,
}; };
use secrecy::SecretString;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
error::{Error, Result}, error::{Error, Result},
query::{Query, QueryFilterList}, query::{ArtifactQuery, QueryFilterList},
}; };
#[derive(Clone)] #[derive(Clone)]
@ -49,9 +48,7 @@ pub struct ConfigData {
/// GitHub API token for downloading GitHub artifacts /// GitHub API token for downloading GitHub artifacts
/// ///
/// Using a fine-grained token with public read permissions is recommended. /// Using a fine-grained token with public read permissions is recommended.
pub github_token: Option<SecretString>, pub github_token: Option<String>,
/// Forgejo/Gitea API tokens by host
pub forgejo_tokens: HashMap<String, SecretString>,
/// Number of artifact indexes to keep in memory /// Number of artifact indexes to keep in memory
pub mem_cache_size: usize, pub mem_cache_size: usize,
/// Get the client IP address from a HTTP request header /// Get the client IP address from a HTTP request header
@ -64,17 +61,10 @@ pub struct ConfigData {
pub real_ip_header: Option<String>, pub real_ip_header: Option<String>,
/// Limit the amount of downloaded artifacts per IP address and minute /// Limit the amount of downloaded artifacts per IP address and minute
pub limit_artifacts_per_min: Option<NonZeroU32>, pub limit_artifacts_per_min: Option<NonZeroU32>,
/// Limit the amount of PR comment API requests per IP address and minute
pub limit_pr_comments_per_min: Option<NonZeroU32>,
/// List of sites/users/repos that can NOT be accessed /// List of sites/users/repos that can NOT be accessed
pub repo_blacklist: QueryFilterList, pub repo_blacklist: QueryFilterList,
/// List of sites/users/repos that can ONLY be accessed /// List of sites/users/repos that can ONLY be accessed
pub repo_whitelist: QueryFilterList, pub repo_whitelist: QueryFilterList,
/// List of suggested code forges (host only, without https://)
///
/// If repo_whitelist is empty, this value is used for the matched sites in the userscript
/// as well as the url placeholder on the home page
pub suggested_sites: Vec<String>,
/// Aliases for sites (Example: `gh => github.com`) /// Aliases for sites (Example: `gh => github.com`)
pub site_aliases: HashMap<String, String>, pub site_aliases: HashMap<String, String>,
/// Maximum file size for the viewer /// Maximum file size for the viewer
@ -94,18 +84,11 @@ impl Default for ConfigData {
max_age_h: NonZeroU32::new(12).unwrap(), max_age_h: NonZeroU32::new(12).unwrap(),
zip_timeout_ms: Some(NonZeroU32::new(1000).unwrap()), zip_timeout_ms: Some(NonZeroU32::new(1000).unwrap()),
github_token: None, github_token: None,
forgejo_tokens: HashMap::new(),
mem_cache_size: 50, mem_cache_size: 50,
real_ip_header: None, real_ip_header: None,
limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()), limit_artifacts_per_min: Some(NonZeroU32::new(5).unwrap()),
limit_pr_comments_per_min: Some(NonZeroU32::new(5).unwrap()),
repo_blacklist: QueryFilterList::default(), repo_blacklist: QueryFilterList::default(),
repo_whitelist: QueryFilterList::default(), repo_whitelist: QueryFilterList::default(),
suggested_sites: vec![
String::from("codeberg.org"),
String::from("github.com"),
String::from("gitea.com"),
],
site_aliases: HashMap::new(), site_aliases: HashMap::new(),
viewer_max_size: Some(NonZeroU32::new(500_000).unwrap()), viewer_max_size: Some(NonZeroU32::new(500_000).unwrap()),
} }
@ -131,7 +114,7 @@ impl ConfigData {
impl Config { impl Config {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
let data = let data =
envy::from_env::<ConfigData>().map_err(|e| Error::Other(e.to_string().into()))?; envy::from_env::<ConfigData>().map_err(|e| Error::Internal(e.to_string().into()))?;
Self::from_data(data) Self::from_data(data)
} }
@ -171,16 +154,7 @@ impl Config {
&self.i.main_url &self.i.main_url
} }
pub fn example_site(&self) -> &str { pub fn check_filterlist(&self, query: &ArtifactQuery) -> Result<()> {
self.i
.data
.repo_whitelist
.first_host()
.or_else(|| self.i.data.suggested_sites.first().map(|s| s.as_str()))
.unwrap_or("codeberg.org")
}
pub fn check_filterlist<Q: Query>(&self, query: &Q) -> Result<()> {
if !self.i.data.repo_blacklist.passes(query, true) { if !self.i.data.repo_blacklist.passes(query, true) {
Err(Error::Forbidden("repository is blacklisted".into())) Err(Error::Forbidden("repository is blacklisted".into()))
} else if !self.i.data.repo_whitelist.passes(query, false) { } else if !self.i.data.repo_whitelist.passes(query, false) {

View file

@ -20,8 +20,8 @@ pub enum Error {
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error("Zip: {0}")] #[error("Zip: {0}")]
Zip(#[from] async_zip::error::ZipError), Zip(#[from] async_zip::error::ZipError),
#[error("Error: {0}")] #[error("Internal error: {0}")]
Other(Cow<'static, str>), Internal(Cow<'static, str>),
#[error("Invalid request: {0}")] #[error("Invalid request: {0}")]
BadRequest(Cow<'static, str>), BadRequest(Cow<'static, str>),
@ -58,13 +58,13 @@ impl From<reqwest::Error> for Error {
impl From<std::num::TryFromIntError> for Error { impl From<std::num::TryFromIntError> for Error {
fn from(value: std::num::TryFromIntError) -> Self { fn from(value: std::num::TryFromIntError) -> Self {
Self::Other(value.to_string().into()) Self::Internal(value.to_string().into())
} }
} }
impl From<url::ParseError> for Error { impl From<url::ParseError> for Error {
fn from(value: url::ParseError) -> Self { fn from(value: url::ParseError) -> Self {
Self::Other(value.to_string().into()) Self::Internal(value.to_string().into())
} }
} }

View file

@ -1,7 +1,4 @@
use std::{ use std::{collections::HashMap, str::FromStr};
collections::{BTreeSet, HashMap},
str::FromStr,
};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::{Captures, Regex}; use regex::{Captures, Regex};
@ -148,11 +145,7 @@ impl ArtifactQuery {
} }
impl RunQuery { impl RunQuery {
pub fn from_forge_url(url: &str) -> Result<Self> { pub fn from_forge_url(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
Self::from_forge_url_alias(url, &HashMap::new())
}
pub fn from_forge_url_alias(url: &str, aliases: &HashMap<String, String>) -> Result<Self> {
let (host, mut path_segs) = util::parse_url(url)?; let (host, mut path_segs) = util::parse_url(url)?;
let user = path_segs let user = path_segs
@ -164,8 +157,8 @@ impl RunQuery {
.ok_or(Error::BadRequest("no repository".into()))? .ok_or(Error::BadRequest("no repository".into()))?
.to_ascii_lowercase(); .to_ascii_lowercase();
if path_segs.next().is_none_or(|s| s != "actions") if !path_segs.next().is_some_and(|s| s == "actions")
|| path_segs.next().is_none_or(|s| s != "runs") || !path_segs.next().is_some_and(|s| s == "runs")
{ {
return Err(Error::BadRequest("invalid Actions URL".into())); return Err(Error::BadRequest("invalid Actions URL".into()));
} }
@ -251,11 +244,11 @@ impl From<ArtifactQuery> for RunQuery {
fn encode_domain(s: &str, bias: char) -> String { fn encode_domain(s: &str, bias: char) -> String {
// Check if the character at the given position is in the middle of the string // Check if the character at the given position is in the middle of the string
// and it is not followed by escape seq numbers or further escapable characters // and it is not followed by escape seq numbers or further escapable characters
let is_mid_single = |str: &str, pos: usize| -> bool { let is_mid_single = |pos: usize| -> bool {
if pos == 0 || pos >= (str.len() - 1) { if pos == 0 || pos >= (s.len() - 1) {
return false; return false;
} }
let next_char = str[pos..].chars().nth(1).unwrap(); let next_char = s[pos..].chars().nth(1).unwrap();
!('0'..='2').contains(&next_char) && !matches!(next_char, '-' | '.' | '_') !('0'..='2').contains(&next_char) && !matches!(next_char, '-' | '.' | '_')
}; };
@ -264,7 +257,7 @@ fn encode_domain(s: &str, bias: char) -> String {
let mut last_pos = 0; let mut last_pos = 0;
for (pos, c) in s.match_indices('-') { for (pos, c) in s.match_indices('-') {
buf += &s[last_pos..pos]; buf += &s[last_pos..pos];
if bias == '-' && is_mid_single(s, pos) { if bias == '-' && is_mid_single(pos) {
buf.push('-'); buf.push('-');
} else { } else {
buf += "-1"; buf += "-1";
@ -279,7 +272,7 @@ fn encode_domain(s: &str, bias: char) -> String {
for (pos, c) in buf.match_indices(['.', '_']) { for (pos, c) in buf.match_indices(['.', '_']) {
buf2 += &buf[last_pos..pos]; buf2 += &buf[last_pos..pos];
let cchar = c.chars().next().unwrap(); let cchar = c.chars().next().unwrap();
if cchar == bias && is_mid_single(&buf, pos) { if cchar == bias && is_mid_single(pos) {
buf2.push('-'); buf2.push('-');
} else if cchar == '.' { } else if cchar == '.' {
buf2 += "-0" buf2 += "-0"
@ -335,12 +328,12 @@ impl FromStr for QueryFilter {
if let Some(user) = &user { if let Some(user) = &user {
if !RE_REPO_NAME.is_match(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 let Some(repo) = &repo {
if !RE_REPO_NAME.is_match(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,25 +367,13 @@ impl FromStr for QueryFilterList {
} }
impl QueryFilterList { impl QueryFilterList {
pub fn passes<Q: Query>(&self, query: &Q, blacklist: bool) -> bool { pub fn passes(&self, query: &ArtifactQuery, blacklist: bool) -> bool {
if self.0.is_empty() { if self.0.is_empty() {
true true
} else { } else {
self.0.iter().any(|itm| itm.passes(query)) ^ blacklist self.0.iter().any(|itm| itm.passes(query)) ^ blacklist
} }
} }
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn first_host(&self) -> Option<&str> {
self.0.first().map(|f| f.host.as_str())
}
pub fn hosts(&self) -> BTreeSet<&str> {
self.0.iter().map(|f| f.host.as_str()).collect()
}
} }
impl<'de> Deserialize<'de> for QueryFilterList { impl<'de> Deserialize<'de> for QueryFilterList {
@ -402,7 +383,7 @@ impl<'de> Deserialize<'de> for QueryFilterList {
{ {
struct QueryFilterListVisitor; struct QueryFilterListVisitor;
impl Visitor<'_> for QueryFilterListVisitor { impl<'de> Visitor<'de> for QueryFilterListVisitor {
type Value = QueryFilterList; type Value = QueryFilterList;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {

View file

@ -1,11 +0,0 @@
---
source: src/app.rs
expression: res
---
### <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/1" target="_blank" rel="noopener noreferrer">Latest build artifacts</a>
Run [#1](https://code.thetadev.de/thetadev/test-actions/actions/runs/1) · [15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397) · 15.06.2024 01:30:00 UTC
🏠 <a href="https://code-thetadev-de--thetadev--test-actions--1-1.localhost:3000" target="_blank" rel="noopener noreferrer">Hello World ;-)</a><br>
<a href="https://code-thetadev-de--thetadev--test-actions--1-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">Test</a><br>
<!--NEXT_PREV - [#1](https://code.thetadev.de/thetadev/test-actions/actions/runs/1) [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/1" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--1-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--1-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 01:30:00 UTC) -->

View file

@ -1,16 +0,0 @@
---
source: src/app.rs
expression: res
---
### <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/2" target="_blank" rel="noopener noreferrer">Latest build artifacts</a>
Run [#2](https://code.thetadev.de/thetadev/test-actions/actions/runs/2) · [25eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/25eed48a8382513147a949117ef4aa659989d397) · 15.06.2024 02:30:00 UTC
🏠 <a href="https://code-thetadev-de--thetadev--test-actions--2-1.localhost:3000" target="_blank" rel="noopener noreferrer">Hello World ;-)</a><br>
<a href="https://code-thetadev-de--thetadev--test-actions--2-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">Test</a><br>
<details>
<summary>Previous builds</summary>
- [#1](https://code.thetadev.de/thetadev/test-actions/actions/runs/1) [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/1" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--1-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--1-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 01:30:00 UTC)
<!--NEXT_PREV - [#2](https://code.thetadev.de/thetadev/test-actions/actions/runs/2) [[25eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/25eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/2" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--2-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--2-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 02:30:00 UTC) -->
</details>

View file

@ -1,17 +0,0 @@
---
source: src/app.rs
expression: res
---
### <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/3" target="_blank" rel="noopener noreferrer">Latest build artifacts</a>
Run [#3](https://code.thetadev.de/thetadev/test-actions/actions/runs/3) · [35eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/35eed48a8382513147a949117ef4aa659989d397) · 15.06.2024 03:30:00 UTC
🏠 <a href="https://code-thetadev-de--thetadev--test-actions--3-1.localhost:3000" target="_blank" rel="noopener noreferrer">Hello World ;-)</a><br>
<a href="https://code-thetadev-de--thetadev--test-actions--3-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">Test</a><br>
<details>
<summary>Previous builds</summary>
- [#1](https://code.thetadev.de/thetadev/test-actions/actions/runs/1) [[15eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/15eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/1" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--1-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--1-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 01:30:00 UTC)
- [#2](https://code.thetadev.de/thetadev/test-actions/actions/runs/2) [[25eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/25eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/2" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--2-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--2-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 02:30:00 UTC)
<!--NEXT_PREV - [#3](https://code.thetadev.de/thetadev/test-actions/actions/runs/3) [[35eed48a83](https://code.thetadev.de/thetadev/test-actions/commit/35eed48a8382513147a949117ef4aa659989d397)] <a href="https://localhost:3000/?url=https://code.thetadev.de/thetadev/test-actions/actions/runs/3" target="_blank" rel="noopener noreferrer">Artifacts</a>: <a href="https://code-thetadev-de--thetadev--test-actions--3-1.localhost:3000" target="_blank" rel="noopener noreferrer">`Hello`</a>, <a href="https://code-thetadev-de--thetadev--test-actions--3-2.localhost:3000/junit.xml?viewer=1" target="_blank" rel="noopener noreferrer">`Test`</a> (15.06.2024 03:30:00 UTC) -->
</details>

View file

@ -1,5 +1,3 @@
use std::collections::BTreeMap;
use crate::{ use crate::{
artifact_api::Artifact, artifact_api::Artifact,
cache::{Crc32, ListingEntry, Size}, cache::{Crc32, ListingEntry, Size},
@ -13,7 +11,6 @@ use yarte::{Render, Template};
#[template(path = "index")] #[template(path = "index")]
pub struct Index<'a> { pub struct Index<'a> {
pub main_url: &'a str, pub main_url: &'a str,
pub example_site: &'a str,
} }
#[derive(Template)] #[derive(Template)]
@ -68,16 +65,6 @@ pub struct Junit {
pub suites: TestSuites, pub suites: TestSuites,
} }
#[derive(Template)]
#[template(path = "userscript")]
pub struct Userscript<'a> {
pub main_url: &'a str,
pub root_domain: &'a str,
pub no_https: bool,
pub forge_urls: Vec<String>,
pub aliases: &'a BTreeMap<&'a str, &'a str>,
}
pub struct ViewerLink { pub struct ViewerLink {
pub id: &'static str, pub id: &'static str,
pub name: &'static str, pub name: &'static str,

View file

@ -194,7 +194,7 @@ pub fn get_ip_address(request: &Request, real_ip_header: Option<&str>) -> Result
let socket_addr = request let socket_addr = request
.extensions() .extensions()
.get::<ConnectInfo<SocketAddr>>() .get::<ConnectInfo<SocketAddr>>()
.ok_or(Error::Other("could get request ip address".into()))? .ok_or(Error::Internal("could get request ip address".into()))?
.0; .0;
Ok(socket_addr.ip()) Ok(socket_addr.ip())
} }
@ -263,15 +263,6 @@ pub struct ErrorJson {
msg: String, msg: String,
} }
impl ErrorJson {
pub fn ok<S: Into<String>>(msg: S) -> Self {
Self {
status: 200,
msg: msg.into(),
}
}
}
impl From<Error> for ErrorJson { impl From<Error> for ErrorJson {
fn from(value: Error) -> Self { fn from(value: Error) -> Self {
Self { Self {
@ -289,31 +280,10 @@ impl From<http::Error> for ErrorJson {
impl IntoResponse for ErrorJson { impl IntoResponse for ErrorJson {
fn into_response(self) -> Response { fn into_response(self) -> Response {
Response::builder().status(self.status).json(&self).unwrap() Response::builder().json(&self).unwrap()
} }
} }
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
}
pub fn split_icon_prefix(s: &str) -> (&str, &str) {
if let Some((i, c)) = s
.char_indices()
.find(|(_, c)| c.is_ascii() || c.is_alphanumeric())
{
if i > 0 && c == ' ' && s.get(i + 1..).is_some() {
return (&s[..i + 1], &s[i + 1..]);
}
}
("", s)
}
#[cfg(test)] #[cfg(test)]
pub(crate) mod tests { pub(crate) mod tests {
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -402,16 +372,4 @@ pub(crate) mod tests {
let res = super::filename_ext(filename); let res = super::filename_ext(filename);
assert_eq!(res, expect); assert_eq!(res, expect);
} }
#[rstest]
#[case("🧪 Test", ("🧪 ", "Test"))]
#[case("🧪👨‍👩‍👦 Test", ("🧪👨‍👩‍👦 ", "Test"))]
#[case("🧪 👨‍👩‍👦 Test", ("🧪 ", "👨‍👩‍👦 Test"))]
#[case("", ("", ""))]
#[case("Test", ("", "Test"))]
#[case("運命 Test", ("", "運命 Test"))]
fn split_icon_prefix(#[case] s: &str, #[case] expect: (&str, &str)) {
let res = super::split_icon_prefix(s);
assert_eq!(res, expect);
}
} }

View file

@ -24,7 +24,7 @@
name="url" name="url"
type="text" type="text"
required required
placeholder="{{example_site}}/user/repo/actions/runs/42" placeholder="codeberg.org/username/repo/actions/runs/42"
style="flex-grow: 1" style="flex-grow: 1"
/> />
<button class="btn" type="submit">Browse</button> <button class="btn" type="submit">Browse</button>
@ -40,10 +40,6 @@
Artifactview Artifactview
</a> </a>
{{~crate::app::VERSION}} {{~crate::app::VERSION}}
<p class="light">
Install the <a href="/artifactview.user.js">Artifactview userscript</a> for <a href="https://addons.mozilla.org/de/firefox/addon/greasemonkey/" target="_blank" rel="noopener noreferrer">Greasemonkey</a>/<a href="https://violentmonkey.github.io/" target="_blank" rel="noopener noreferrer">Violentmonkey</a>
to add a "View artifact" button to your code forge.
</p>
<p class="light"> <p class="light">
<b>Disclaimer:</b> <b>Disclaimer:</b>
Artifactview does not host any websites, the data is fetched from the respective Artifactview does not host any websites, the data is fetched from the respective

View file

@ -22,10 +22,10 @@
<th><a href="?C=N&amp;O=A">Name</a>&nbsp;<a <th><a href="?C=N&amp;O=A">Name</a>&nbsp;<a
href="?C=N&amp;O=D" href="?C=N&amp;O=D"
>&nbsp;&darr;&nbsp;</a></th> >&nbsp;&darr;&nbsp;</a></th>
<th>CRC32</th>
<th><a href="?C=S&amp;O=A">Size</a>&nbsp;<a <th><a href="?C=S&amp;O=A">Size</a>&nbsp;<a
href="?C=S&amp;O=D" href="?C=S&amp;O=D"
>&nbsp;&darr;&nbsp;</a></th> >&nbsp;&darr;&nbsp;</a></th>
<th>CRC32</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -45,8 +45,8 @@
<span class="name">{{name}}</span> <span class="name">{{name}}</span>
</a> </a>
</td> </td>
<td>{{#if is_dir}}&mdash;{{else}}{{crc32}}{{/if}}</td>
<td>{{#if is_dir}}&mdash;{{else}}{{size}}{{/if}}</td> <td>{{#if is_dir}}&mdash;{{else}}{{size}}{{/if}}</td>
<td>{{#if is_dir}}&mdash;{{else}}{{crc32}}{{/if}}</td>
</tr> </tr>
{{/each}} {{/each}}
</tbody> </tbody>

View file

@ -1,108 +0,0 @@
// ==UserScript==
// @name Artifactview
// @version 0.1.0
// @description Adds a "View artifact" button to GitHub/Gitea/Forgejo CI artifacts using the Artifactview instance {{{root_domain}}}
// @author ThetaDev
// @icon {{{main_url}}}/favicon.ico
// @homepageURL {{{main_url}}}
// @run-at document-idle
// @grant none
// @require https://greasyfork.org/scripts/28721-mutations/code/mutations.js?version=1108163
{{~#each forge_urls}}
// @match {{{this}}}
{{~/each}}
// ==/UserScript==
// Copyright (C) 2024 ThetaDev, MIT License
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
const AV_HOST = "{{{root_domain}}}";
const NO_HTTPS = {{{no_https}}};
const ALIASES = {{ @json_pretty aliases }};
function encodeDomain(s, bias) {
// Check if the character at the given position is in the middle of the string
// and it is not followed by escape seq numbers or further escapable characters
const isMidSingle = (str, pos) => {
if (pos === 0) return false;
const nc = str[pos + 1];
return nc && !nc.match(/^[0-2\-\._]$/)
};
// Escape dashes
let buf = "";
let last_pos = 0;
while (true) {
let pos = s.indexOf("-", last_pos);
if (pos < 0) break;
buf += s.substring(last_pos, pos);
if (bias === "-" && isMidSingle(s, pos)) {
buf += "-";
} else {
buf += "-1";
}
last_pos = pos + 1;
}
buf += s.substring(last_pos);
// Replace special chars [._]
let buf2 = "";
last_pos = 0;
for (let i = 0; i < buf.length; i++) {
if (buf[i] === "." || buf[i] === "_") {
if (buf[i] === bias && isMidSingle(buf, i)) {
buf2 += "-";
} else if (buf[i] == ".") {
buf2 += "-0";
} else {
buf2 += "-2";
}
} else {
buf2 += buf[i];
}
}
return buf2;
}
function queryURL(host, user, repo, run, artifact) {
const h = ALIASES[host] ?? encodeDomain(host, ".");
return `http${NO_HTTPS ? "" : "s"}://${h}--${encodeDomain(user, "-")}--${encodeDomain(repo, "-")}--${run}-${artifact}.${AV_HOST}`;
}
const url = new URL(window.location.href);
const m = url.pathname.match(/^\/([\w\-\.]+)\/([\w\-\.]+)\/actions\/runs\/(\d+)/);
const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" class="svg ui text black job-artifacts-icon octicon Button-visual"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"></path></svg>`;
if (m) {
if (url.host === "github.com") {
const init = () => document.querySelectorAll(`a[data-test-selector="download-artifact-button"]:not([data-has-view-button="true"])`).forEach((elm) => {
const artifact = elm.getAttribute("href").match(/\d+$/)[0];
elm.insertAdjacentHTML("beforebegin", `<a href="${queryURL(url.host, m[1], m[2], m[3], artifact)}" title="View artifact" target="_blank" rel="noopener noreferrer" class="Button Button--iconOnly Button--invisible Button--medium">${ICON}</a>`);
elm.setAttribute("data-has-view-button", "true");
});
document.addEventListener("ghmo:container", init);
init();
} else {
const rav = document.getElementById("repo-action-view");
new MutationObserver((changes, observer) => {
if (changes.find((c) => Array.from(c.addedNodes).find((n) => n.className === "job-artifacts"))) {
document.querySelectorAll(".job-artifacts-item").forEach((elm, i) => {
const delBtn = elm.querySelector(".job-artifacts-delete");
if (delBtn) {
const wrapper = document.createElement("div");
wrapper.classList.add("tw-flex", "tw-gap-4", "tw-justify-end");
wrapper.innerHTML = `<a href="${queryURL(url.host, m[1], m[2], m[3], i + 1)}" title="View artifact" target="_blank" rel="noopener noreferrer">${ICON}</a>`;
elm.insertAdjacentElement("beforeend", wrapper);
wrapper.appendChild(delBtn);
}
});
observer.disconnect();
}
}).observe(rav, { childList: true, subtree: true });
}
}

View file

@ -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
}
]
}
}

View file

@ -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"
}
}

View file

@ -242,8 +242,8 @@ fn parse_listing(doc: &Html) -> Vec<FileEntry> {
FileEntry { FileEntry {
name, name,
crc32: parts.next().expect("crc32"),
size: parts.next().expect("size"), size: parts.next().expect("size"),
crc32: parts.next().expect("crc32"),
} }
}) })
.collect() .collect()
@ -430,25 +430,3 @@ async fn compressed(server: TestAv) {
let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap(); let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap();
assert_eq!(buf, expect); assert_eq!(buf, expect);
} }
#[rstest]
#[tokio::test]
async fn userscript(server: TestAv) {
let resp = server.get("", "/artifactview.user.js").await;
resp.assert_status_ok();
let script = resp.text();
assert!(script.starts_with("// ==UserScript==\n"));
}
#[rstest]
#[tokio::test]
/// Redirect user to the directory path if index.html or directory listing is served
async fn index_redirect(server: TestAv) {
let resp = server.get(S1, "/sites").await;
resp.assert_status(StatusCode::PERMANENT_REDIRECT);
assert_eq!(resp.header(header::LOCATION), "/sites/");
let resp = server.get(S1, "/junit").await;
resp.assert_status(StatusCode::PERMANENT_REDIRECT);
assert_eq!(resp.header(header::LOCATION), "/junit/");
}