Merge branch 'main'
This commit is contained in:
@@ -29,7 +29,7 @@ jobs:
|
||||
gobuild:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend deps-tools
|
||||
- run: TAGS="bindata" make backend
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
include:
|
||||
- { tags: "bindata", target: "lint-backend" }
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
with:
|
||||
lint-cache: "true"
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
if: github.repository == 'go-gitea/gitea' # prevent running on forks
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: renovatebot/github-action@693b9ef15eec82123529a37c782242f091365961 # v46.1.14
|
||||
with:
|
||||
renovate-version: ${{ env.RENOVATE_VERSION }}
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
with:
|
||||
upload_sources: true
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
e2e: ${{ steps.changes.outputs.e2e }}
|
||||
shell: ${{ steps.changes.outputs.shell }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
with:
|
||||
lint-cache: "true"
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
with:
|
||||
cache: "false"
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend deps-tools
|
||||
- run: make --always-make checks-backend # ensure the "go-licenses" make target runs
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/node-setup
|
||||
- run: make deps-frontend
|
||||
- run: make lint-frontend
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend generate-go
|
||||
# no frontend build here as backend should be able to build, even without any frontend files
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
ports:
|
||||
- "9000:9000"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- uses: ./.github/actions/pgsql-shard
|
||||
with:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
ports:
|
||||
- "9000:9000"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- uses: ./.github/actions/pgsql-shard
|
||||
with:
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend
|
||||
- run: make backend
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
ports:
|
||||
- 10000:10000
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- name: Add hosts to /etc/hosts
|
||||
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 minio devstoreaccount1.azurite.local mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts'
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
- "587:587"
|
||||
- "993:993"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- name: Add hosts to /etc/hosts
|
||||
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts'
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
ports:
|
||||
- 10000:10000
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- name: Add hosts to /etc/hosts
|
||||
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql devstoreaccount1.azurite.local" | sudo tee -a /etc/hosts'
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/docker-dryrun
|
||||
with:
|
||||
platform: linux/amd64
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/docker-dryrun
|
||||
with:
|
||||
platform: linux/arm64
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/docker-dryrun
|
||||
with:
|
||||
platform: linux/riscv64
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: ./.github/actions/go-setup
|
||||
- uses: ./.github/actions/node-setup
|
||||
- run: make deps-frontend
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Base-branch checkout only: pull_request_target runs with elevated token; never run PR-head code here.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@acca2b1b2070338fb9fd1ca27ecee81d687e58e5 # v6.1.2
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@acca2b1b2070338fb9fd1ca27ecee81d687e58e5 # v6.1.2
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@acca2b1b2070338fb9fd1ca27ecee81d687e58e5 # v6.1.2
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# fetch all commits instead of only the last as some branches are long lived and could have many between versions
|
||||
# fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567
|
||||
- run: git fetch --unshallow --quiet --tags --force
|
||||
|
||||
@@ -1,208 +1,23 @@
|
||||
# Gitea
|
||||
# M8SH - decentralized swiss knife
|
||||
|
||||
[](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly")
|
||||
[](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
|
||||
[](https://goreportcard.com/report/gitea.dev "Go Report Card")
|
||||
[](https://pkg.go.dev/gitea.dev "GoDoc")
|
||||
[](https://github.com/go-gitea/gitea/releases/latest "GitHub release")
|
||||
[](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
|
||||
[](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
|
||||
[](https://opensource.org/licenses/MIT "License: MIT")
|
||||
[](https://translate.gitea.com "Crowdin")
|
||||
Project is under development.
|
||||
|
||||
[繁體中文](./README.zh-tw.md) | [简体中文](./README.zh-cn.md)
|
||||
Roadmap:
|
||||
|
||||
## Purpose
|
||||
- decentralized GPG-based registration, authorization and authentication (further when clients are developed - GPG would be integrated into client and invoked via secure bridge for fully password-less and input-less flow, preseving security level of GPG) (usage of external email domain assumes it's an external user and decetralized features will be disabled)
|
||||
- integrated email server (ui, rest)
|
||||
- integrated messenger (ui, rest)
|
||||
- integrated search engine (seach over external gitea instances repositories, specific tags for repos to be indexed, view external things from home instance)
|
||||
- integrated posts, articles
|
||||
- integrated videos, reels
|
||||
- integrated music player
|
||||
- integrated VPN (amnesia)
|
||||
|
||||
The goal of this project is to make the easiest, fastest, and most
|
||||
painless way of setting up a self-hosted Git service.
|
||||
|
||||
As Gitea is written in Go, it works across **all** the platforms and
|
||||
architectures that are supported by Go, including Linux, macOS, and
|
||||
Windows on x86, amd64, ARM and PowerPC architectures.
|
||||
This project has been
|
||||
[forked](https://blog.gitea.com/welcome-to-gitea/) from
|
||||
[Gogs](https://gogs.io) since November of 2016, but a lot has changed.
|
||||
|
||||
For online demonstrations, you can visit [demo.gitea.com](https://demo.gitea.com).
|
||||
|
||||
For accessing free Gitea service (with a limited number of repositories), you can visit [gitea.com](https://gitea.com/user/login).
|
||||
|
||||
To quickly deploy your own dedicated Gitea instance on Gitea Cloud, you can start a free trial at [cloud.gitea.com](https://cloud.gitea.com).
|
||||
|
||||
## Documentation
|
||||
|
||||
You can find comprehensive documentation on our official [documentation website](https://docs.gitea.com/).
|
||||
|
||||
It includes installation, administration, usage, development, contributing guides, and more to help you get started and explore all features effectively.
|
||||
|
||||
If you have any suggestions or would like to contribute to it, you can visit the [documentation repository](https://gitea.com/gitea/docs)
|
||||
|
||||
## Building
|
||||
|
||||
From the root of the source tree, run:
|
||||
|
||||
TAGS="bindata" make build
|
||||
|
||||
The `build` target is split into two sub-targets:
|
||||
|
||||
- `make backend` which requires [Go Stable](https://go.dev/dl/), the required version is defined in [go.mod](/go.mod).
|
||||
- `make frontend` which requires [Node.js LTS](https://nodejs.org/en/download/) or greater and [pnpm](https://pnpm.io/installation).
|
||||
|
||||
Internet connectivity is required to download the go and npm modules. When building from the official source tarballs which include pre-built frontend files, the `frontend` target will not be triggered, making it possible to build without Node.js.
|
||||
|
||||
More info: https://docs.gitea.com/installation/install-from-source
|
||||
|
||||
## Using
|
||||
|
||||
After building, a binary file named `gitea` will be generated in the root of the source tree by default. To run it, use:
|
||||
|
||||
./gitea web
|
||||
|
||||
> [!NOTE]
|
||||
> If you're interested in using our APIs, we have experimental support with [documentation](https://docs.gitea.com/api).
|
||||
|
||||
## Contributing
|
||||
|
||||
Expected workflow is: Fork -> Patch -> Push -> Pull Request
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 1. **YOU MUST READ THE [CONTRIBUTORS GUIDE](CONTRIBUTING.md) BEFORE STARTING TO WORK ON A PULL REQUEST.**
|
||||
> 2. If you have found a vulnerability in the project, please write privately to **security@gitea.io**. Thanks!
|
||||
|
||||
## Translating
|
||||
|
||||
[](https://translate.gitea.com)
|
||||
|
||||
Translations are done through [Crowdin](https://translate.gitea.com). If you want to translate to a new language, ask one of the managers in the Crowdin project to add a new language there.
|
||||
|
||||
You can also just create an issue for adding a language or ask on Discord on the #translation channel. If you need context or find some translation issues, you can leave a comment on the string or ask on Discord. For general translation questions there is a section in the docs. Currently a bit empty, but we hope to fill it as questions pop up.
|
||||
|
||||
Get more information from [documentation](https://docs.gitea.com/contributing/localization).
|
||||
|
||||
## Official and Third-Party Projects
|
||||
|
||||
We provide an official [go-sdk](https://gitea.com/gitea/go-sdk), a CLI tool called [tea](https://gitea.com/gitea/tea) and an [action runner](https://gitea.com/gitea/act_runner) for Gitea Action.
|
||||
|
||||
We maintain a list of Gitea-related projects at [gitea/awesome-gitea](https://gitea.com/gitea/awesome-gitea), where you can discover more third-party projects, including SDKs, plugins, themes, and more.
|
||||
|
||||
## Communication
|
||||
|
||||
[](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
|
||||
|
||||
If you have questions that are not covered by the [documentation](https://docs.gitea.com/), you can get in contact with us on our [Discord server](https://discord.gg/Gitea) or create a post in the [discourse forum](https://forum.gitea.com/).
|
||||
|
||||
## Authors
|
||||
|
||||
- [Maintainers](https://github.com/orgs/go-gitea/people)
|
||||
- [Contributors](https://github.com/go-gitea/gitea/graphs/contributors)
|
||||
- [Translators](options/locale/TRANSLATORS)
|
||||
|
||||
## Backers
|
||||
|
||||
Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/gitea#backer)]
|
||||
|
||||
<a href="https://opencollective.com/gitea#backers" target="_blank"><img src="https://opencollective.com/gitea/backers.svg?width=890"></a>
|
||||
|
||||
## Sponsors
|
||||
|
||||
Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/gitea#sponsor)]
|
||||
|
||||
<a href="https://opencollective.com/gitea/sponsor/0/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/0/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/1/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/1/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/2/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/2/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/3/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/3/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/4/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/4/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/5/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/5/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/6/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/6/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/7/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/7/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/8/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/8/avatar.svg"></a>
|
||||
<a href="https://opencollective.com/gitea/sponsor/9/website" target="_blank"><img src="https://opencollective.com/gitea/sponsor/9/avatar.svg"></a>
|
||||
|
||||
## FAQ
|
||||
|
||||
**How do you pronounce Gitea?**
|
||||
|
||||
Gitea is pronounced [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY) as in "gi-tea" with a hard g.
|
||||
|
||||
**Why is this not hosted on a Gitea instance?**
|
||||
|
||||
We're [working on it](https://github.com/go-gitea/gitea/issues/1029).
|
||||
|
||||
**Where can I find the security patches?**
|
||||
|
||||
In the [release log](https://github.com/go-gitea/gitea/releases) or the [change log](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md), search for the keyword `SECURITY` to find the security patches.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
See the [LICENSE](https://github.com/go-gitea/gitea/blob/main/LICENSE) file
|
||||
for the full license text.
|
||||
|
||||
## Further information
|
||||
|
||||
<details>
|
||||
<summary>Looking for an overview of the interface? Check it out!</summary>
|
||||
|
||||
### Login/Register Page
|
||||
|
||||

|
||||

|
||||
|
||||
### User Dashboard
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### User Profile
|
||||
|
||||

|
||||
|
||||
### Explore
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
### Repository
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
#### Repository Issue
|
||||
|
||||

|
||||

|
||||
|
||||
#### Repository Pull Requests
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
#### Repository Actions
|
||||
|
||||

|
||||

|
||||
|
||||
#### Repository Activity
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### Organization
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
<!--
|
||||
- integrated cloud storage
|
||||
- integrated calls
|
||||
- integrated video-conferences
|
||||
- integrated stickers
|
||||
- integrated NFT assets, crypto-wallets
|
||||
-->
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -671,18 +672,18 @@ func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error)
|
||||
func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0)
|
||||
|
||||
if c, err := cancelOneJob(ctx, caller); err != nil {
|
||||
return cancelledJobs, err
|
||||
} else if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
}
|
||||
|
||||
attemptJobs, err := GetRunJobsByRunAndAttemptID(ctx, caller.RunID, caller.RunAttemptID)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
|
||||
for _, c := range CollectAllDescendantJobs(caller, attemptJobs) {
|
||||
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
|
||||
// so each child must reach its final state before its parent caller is re-aggregated.
|
||||
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
|
||||
descendants := CollectAllDescendantJobs(caller, attemptJobs)
|
||||
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
||||
|
||||
for _, c := range descendants {
|
||||
cancelled, err := cancelOneJob(ctx, c)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
@@ -691,5 +692,11 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob) ([]*ActionR
|
||||
cancelledJobs = append(cancelledJobs, cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := cancelOneJob(ctx, caller); err != nil {
|
||||
return cancelledJobs, err
|
||||
} else if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
}
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
@@ -131,3 +131,69 @@ func TestGetPriorAttemptChildrenByParent(t *testing.T) {
|
||||
assertAttempt1Children(t, out)
|
||||
})
|
||||
}
|
||||
|
||||
// A reusable caller subtree with a Blocked descendant (e.g. a nested caller stuck on an invalid `uses:`) must aggregate to Cancelled, when the run is cancelled.
|
||||
func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancel-nested-caller",
|
||||
RepoID: 4,
|
||||
Index: 9701,
|
||||
OwnerID: 1,
|
||||
WorkflowID: "caller.yaml",
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: StatusBlocked,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
|
||||
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusBlocked}
|
||||
require.NoError(t, db.Insert(ctx, attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||
|
||||
newJob := func(name string, attemptJobID, parentID int64, callUses string) *ActionRunJob {
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: attempt.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: name,
|
||||
JobID: name,
|
||||
Attempt: 1,
|
||||
Status: StatusBlocked,
|
||||
AttemptJobID: attemptJobID,
|
||||
IsReusableCaller: true,
|
||||
CallUses: callUses,
|
||||
ParentJobID: parentID,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
return job
|
||||
}
|
||||
|
||||
// outer: a valid top-level caller that expanded; inner: a nested caller stuck Blocked (invalid uses, never expands).
|
||||
outer := newJob("outer", 1, 0, "./.gitea/workflows/lib.yml")
|
||||
inner := newJob("inner", 2, outer.ID, "https://other.example.com/o/r/.gitea/workflows/ci.yml@v1")
|
||||
|
||||
// Cancel all jobs of the attempt, ordered by id (parent before child).
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, run.ID, attempt.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = CancelJobs(ctx, jobs)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, j := range []*ActionRunJob{outer, inner} {
|
||||
got := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: j.ID})
|
||||
assert.Equal(t, StatusCancelled, got.Status, "job %q should be cancelled", j.JobID)
|
||||
}
|
||||
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: attempt.ID})
|
||||
assert.Equal(t, StatusCancelled, gotAttempt.Status, "attempt must aggregate to Cancelled")
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
@@ -860,6 +861,11 @@ func GetCodeOwnersFromContent(ctx context.Context, data string) ([]*CodeOwnerRul
|
||||
return rules, warnings
|
||||
}
|
||||
|
||||
// codeOwnerMatchTimeout bounds a single pattern match so a crafted pattern
|
||||
// cannot stall via catastrophic backtracking. See also the aggregate budget
|
||||
// enforced by the caller across the whole rules×files match loop.
|
||||
const codeOwnerMatchTimeout = 150 * time.Millisecond
|
||||
|
||||
type CodeOwnerRule struct {
|
||||
Rule *regexp2.Regexp // it supports negative lookahead, does better for end users
|
||||
Negative bool
|
||||
@@ -888,6 +894,8 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule,
|
||||
warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err))
|
||||
return nil, warnings
|
||||
}
|
||||
// Bound matching time so user-supplied patterns cannot stall PR creation via catastrophic backtracking.
|
||||
rule.Rule.MatchTimeout = codeOwnerMatchTimeout
|
||||
|
||||
for _, user := range tokens[1:] {
|
||||
user = strings.TrimPrefix(user, "@")
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
@@ -39,6 +41,7 @@ func TestPullRequest(t *testing.T) {
|
||||
t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects)
|
||||
t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine)
|
||||
t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns)
|
||||
t.Run("CodeOwnerPatternMatchTimeout", testCodeOwnerPatternMatchTimeout)
|
||||
t.Run("GetApprovers", testGetApprovers)
|
||||
t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit)
|
||||
t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests)
|
||||
@@ -376,6 +379,22 @@ func testCodeOwnerAbsolutePathPatterns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testCodeOwnerPatternMatchTimeout ensures user-supplied CODEOWNERS patterns
|
||||
// cannot stall pull request processing through catastrophic regex backtracking:
|
||||
// each compiled rule must enforce a bounded match time.
|
||||
func testCodeOwnerPatternMatchTimeout(t *testing.T) {
|
||||
rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), "(a+)+ @user5\n")
|
||||
require.Len(t, rules, 1)
|
||||
|
||||
maliciousInput := strings.Repeat("a", 30) + "X"
|
||||
start := time.Now()
|
||||
_, err := rules[0].Rule.MatchString(maliciousInput)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Error(t, err, "expected MatchTimeout error on pathological input")
|
||||
assert.Less(t, elapsed, time.Second, "match timeout did not bound regex evaluation; took %s", elapsed)
|
||||
}
|
||||
|
||||
func testGetApprovers(t *testing.T) {
|
||||
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5})
|
||||
// Official reviews are already deduplicated. Allow unofficial reviews
|
||||
|
||||
@@ -3774,6 +3774,7 @@
|
||||
"actions.runs.no_matching_online_runner_helper": "No matching online runner with label: %s",
|
||||
"actions.runs.no_job_without_needs": "The workflow must contain at least one job without dependencies.",
|
||||
"actions.runs.no_job": "The workflow must contain at least one job",
|
||||
"actions.runs.invalid_reusable_workflow_uses": "Invalid reusable workflow \"uses\": %s",
|
||||
"actions.runs.actor": "Actor",
|
||||
"actions.runs.status": "Status",
|
||||
"actions.runs.actors_no_select": "All actors",
|
||||
|
||||
+14
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
"packageManager": "pnpm@11.5.1",
|
||||
"engines": {
|
||||
"node": ">= 22.18.0",
|
||||
"pnpm": ">= 11.0.0"
|
||||
@@ -28,7 +28,7 @@
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-rc2",
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@primer/octicons": "19.27.0",
|
||||
"@primer/octicons": "19.28.0",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@replit/codemirror-lang-nix": "6.0.1",
|
||||
"@replit/codemirror-lang-svelte": "6.0.0",
|
||||
@@ -50,14 +50,14 @@
|
||||
"esbuild": "0.28.0",
|
||||
"idiomorph": "0.7.4",
|
||||
"jquery": "4.0.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "4.2.0",
|
||||
"katex": "0.17.0",
|
||||
"mermaid": "11.15.0",
|
||||
"online-3d-viewer": "0.18.0",
|
||||
"pdfobject": "2.3.1",
|
||||
"perfect-debounce": "2.1.0",
|
||||
"postcss": "8.5.15",
|
||||
"rolldown-license-plugin": "3.0.8",
|
||||
"rolldown-license-plugin": "3.0.9",
|
||||
"sortablejs": "1.15.7",
|
||||
"swagger-ui-dist": "5.32.6",
|
||||
"tailwindcss": "3.4.19",
|
||||
@@ -67,7 +67,7 @@
|
||||
"tributejs": "5.1.3",
|
||||
"uint8-to-base64": "0.2.1",
|
||||
"vanilla-colorful": "0.7.2",
|
||||
"vite": "8.0.14",
|
||||
"vite": "8.0.16",
|
||||
"vite-string-plugin": "2.0.4",
|
||||
"vue": "3.5.35",
|
||||
"vue-bar-graph": "2.2.0",
|
||||
@@ -89,11 +89,11 @@
|
||||
"@types/swagger-ui-dist": "3.30.6",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/toastify-js": "1.12.4",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vitest/eslint-plugin": "1.6.18",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"@vitest/eslint-plugin": "1.6.19",
|
||||
"eslint": "10.4.1",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-array-func": "5.1.1",
|
||||
"eslint-plugin-de-morgan": "2.1.2",
|
||||
"eslint-plugin-github": "6.0.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"eslint-plugin-sonarjs": "4.0.3",
|
||||
"eslint-plugin-unicorn": "64.0.0",
|
||||
"eslint-plugin-vue": "10.9.1",
|
||||
"eslint-plugin-vue-scoped-css": "3.1.0",
|
||||
"eslint-plugin-vue-scoped-css": "3.1.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"globals": "17.6.0",
|
||||
"happy-dom": "20.9.0",
|
||||
@@ -119,9 +119,9 @@
|
||||
"stylelint-value-no-unknown-custom-properties": "6.1.1",
|
||||
"svgo": "4.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0",
|
||||
"updates": "17.17.2",
|
||||
"vitest": "4.1.7",
|
||||
"vue-tsc": "3.3.2"
|
||||
"typescript-eslint": "8.60.1",
|
||||
"updates": "17.17.3",
|
||||
"vitest": "4.1.8",
|
||||
"vue-tsc": "3.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+378
-528
File diff suppressed because it is too large
Load Diff
Generated
+1
-1
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 16 16" class="svg octicon-vscode" width="16" height="16"><path d="M10.863 13.919a.8.8 0 0 1-.644.025.8.8 0 0 1-.279-.183L4.816 9.063l-2.232 1.703a.54.54 0 0 1-.691-.031l-.716-.655a.546.546 0 0 1 0-.805L3.112 7.5 1.177 5.725a.546.546 0 0 1 0-.805l.716-.655a.54.54 0 0 1 .691-.031l2.232 1.703L9.94 1.239a.805.805 0 0 1 .923-.159l2.677 1.295c.281.136.46.422.46.736V8h-3.248V4.534L6.864 7.5l3.888 2.966V8H14v3.889c0 .314-.179.6-.46.736z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" class="svg octicon-vscode" width="16" height="16" aria-hidden="true"><path fill="currentColor" fill-rule="evenodd" d="M11.098 1.013a.87.87 0 0 1 .524.073l2.883 1.394a.88.88 0 0 1 .495.793v9.454a.88.88 0 0 1-.495.792l-2.883 1.393a.86.86 0 0 1-.994-.17l-5.519-5.06-2.403 1.835a.58.58 0 0 1-.744-.034l-.772-.705a.59.59 0 0 1 0-.867L3.274 8 1.19 6.088a.59.59 0 0 1 0-.866l.772-.706a.58.58 0 0 1 .744-.034l2.403 1.834 5.519-5.058a.87.87 0 0 1 .47-.245M7.315 8l4.187 3.193V11H11.5V6h.002V4.806z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 513 B After Width: | Height: | Size: 577 B |
@@ -151,7 +151,7 @@ func repoAssignment() func(ctx *context.APIContext) {
|
||||
if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil {
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -626,7 +626,7 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
|
||||
if err == nil {
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, ctx.PathParam("org"), redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetOrgByName", err)
|
||||
ctx.APIErrorNotFound()
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
@@ -862,12 +862,12 @@ func individualPermsChecker(ctx *context.APIContext) {
|
||||
switch ctx.ContextUser.Visibility {
|
||||
case api.VisibleTypePrivate:
|
||||
if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) {
|
||||
ctx.APIErrorNotFound("Visit Project", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
case api.VisibleTypeLimited:
|
||||
if ctx.Doer == nil {
|
||||
ctx.APIErrorNotFound("Visit Project", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func GetUserOrgsPermissions(ctx *context.APIContext) {
|
||||
op := api.OrganizationPermissions{}
|
||||
|
||||
if !organization.HasOrgOrUserVisible(ctx, o, ctx.Doer) {
|
||||
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ func Get(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) {
|
||||
ctx.APIErrorNotFound("HasOrgOrUserVisible", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1164,11 +1164,8 @@ func ActionsEnableWorkflow(ctx *context.APIContext) {
|
||||
func getCurrentRepoActionRunByID(ctx *context.APIContext) *actions_model.ActionRun {
|
||||
runID := ctx.PathParamInt64("run")
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return nil
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
run.Repo = ctx.Repo.Repository
|
||||
@@ -1198,11 +1195,8 @@ func getCurrentRepoActionRunAttemptByNumber(ctx *context.APIContext) (*actions_m
|
||||
|
||||
attemptNum := ctx.PathParamInt64("attempt")
|
||||
attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil
|
||||
}
|
||||
return run, attempt
|
||||
@@ -1454,7 +1448,7 @@ func RerunWorkflowJob(ctx *context.APIContext) {
|
||||
jobID := ctx.PathParamInt64("job_id")
|
||||
jobIdx := slices.IndexFunc(jobs, func(job *actions_model.ActionRunJob) bool { return job.ID == jobID })
|
||||
if jobIdx == -1 {
|
||||
ctx.APIErrorNotFound(util.NewNotExistErrorf("workflow job with id %d", jobID))
|
||||
ctx.APIErrorNotFound("workflow job not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1566,11 +1560,7 @@ func ListWorkflowRunJobs(ctx *context.APIContext) {
|
||||
|
||||
run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
// runID is used as an additional filter next to repoID to ensure that we only list jobs for the specified repoID and runID.
|
||||
@@ -1674,7 +1664,7 @@ func GetWorkflowJob(ctx *context.APIContext) {
|
||||
}
|
||||
|
||||
if !has || job.RepoID != ctx.Repo.Repository.ID {
|
||||
ctx.APIErrorNotFound(util.ErrNotExist)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/routers/common"
|
||||
"gitea.dev/services/context"
|
||||
)
|
||||
@@ -45,11 +42,7 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
|
||||
jobID := ctx.PathParamInt64("job_id")
|
||||
curJob, err := actions_model.GetRunJobByRepoAndID(ctx, ctx.Repo.Repository.ID, jobID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
if err = curJob.LoadRepo(ctx); err != nil {
|
||||
@@ -59,10 +52,6 @@ func DownloadActionsRunJobLogs(ctx *context.APIContext) {
|
||||
|
||||
err = common.DownloadActionsRunJobLogs(ctx.Base, ctx.Repo.Repository, curJob)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func GetBranch(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if !exist {
|
||||
ctx.APIErrorNotFound(err)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func DeleteBranch(ctx *context.APIContext) {
|
||||
if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil {
|
||||
switch {
|
||||
case git.IsErrBranchNotExist(err):
|
||||
ctx.APIErrorNotFound(err)
|
||||
ctx.APIErrorNotFound()
|
||||
case errors.Is(err, repo_service.ErrBranchIsDefault):
|
||||
ctx.APIError(http.StatusForbidden, "can not delete default or pull request target branch")
|
||||
case errors.Is(err, git_model.ErrBranchIsProtected):
|
||||
@@ -446,7 +446,7 @@ func UpdateBranch(ctx *context.APIContext) {
|
||||
if err := repo_service.UpdateBranch(ctx, repo, ctx.Repo.GitRepo, ctx.Doer, branchName, opt.NewCommitID, opt.OldCommitID, opt.Force); err != nil {
|
||||
switch {
|
||||
case git_model.IsErrBranchNotExist(err):
|
||||
ctx.APIErrorNotFound(err)
|
||||
ctx.APIErrorNotFound()
|
||||
case errors.Is(err, util.ErrInvalidArgument):
|
||||
ctx.APIError(http.StatusUnprocessableEntity, err.Error())
|
||||
case git.IsErrPushRejected(err):
|
||||
|
||||
@@ -258,7 +258,7 @@ func GetAllCommits(ctx *context.APIContext) {
|
||||
ctx.APIErrorInternal(err)
|
||||
return
|
||||
} else if commitsCountTotal == 0 {
|
||||
ctx.APIErrorNotFound("FileCommitsCount", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEn
|
||||
}
|
||||
|
||||
if entry.IsDir() || entry.IsSubModule() {
|
||||
ctx.APIErrorNotFound("getBlobForEntry", nil)
|
||||
ctx.APIErrorNotFound()
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -301,18 +301,14 @@ func GetEditorconfig(ctx *context.APIContext) {
|
||||
|
||||
ec, _, err := ctx.Repo.GetEditorconfig(ctx.Repo.Commit)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
fileName := ctx.PathParam("filename")
|
||||
def, err := ec.GetDefinitionForFilename(fileName)
|
||||
if def == nil {
|
||||
ctx.APIErrorNotFound(err)
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound(err.Error())
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, def)
|
||||
@@ -699,10 +695,8 @@ func DeleteFile(ctx *context.APIContext) {
|
||||
func resolveRefCommit(ctx *context.APIContext, ref string, minCommitIDLen ...int) *utils.RefCommit {
|
||||
ref = util.IfZero(ref, ctx.Repo.Repository.DefaultBranch)
|
||||
refCommit, err := utils.ResolveRefCommit(ctx, ctx.Repo.Repository, ref, minCommitIDLen...)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
if err != nil {
|
||||
ctx.APIErrorAuto(err)
|
||||
}
|
||||
return refCommit
|
||||
}
|
||||
@@ -828,11 +822,8 @@ func getRepoContents(ctx *context.APIContext, opts files_service.GetContentsOrLi
|
||||
}
|
||||
ret, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, refCommit, opts)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetContentsOrList", err)
|
||||
return nil
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
@@ -540,16 +540,10 @@ func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 {
|
||||
}
|
||||
|
||||
user, err := user_model.GetUserByName(ctx, userName)
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return 0
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return 0
|
||||
}
|
||||
|
||||
return user.ID
|
||||
}
|
||||
|
||||
@@ -969,11 +963,7 @@ func DeleteIssue(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -447,11 +447,7 @@ func GetIssueComment(ctx *context.APIContext) {
|
||||
|
||||
comment, err := issues_model.GetCommentWithRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -572,11 +568,7 @@ func EditIssueCommentDeprecated(ctx *context.APIContext) {
|
||||
func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) {
|
||||
comment, err := issues_model.GetCommentWithRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -681,11 +673,7 @@ func DeleteIssueCommentDeprecated(ctx *context.APIContext) {
|
||||
func deleteIssueComment(ctx *context.APIContext) {
|
||||
comment, err := issues_model.GetCommentWithRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,7 @@ func GetIssueDependencies(ctx *context.APIContext) {
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound("IsErrIssueNotExist", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -487,11 +483,7 @@ func RemoveIssueBlocking(ctx *context.APIContext) {
|
||||
func getParamsIssue(ctx *context.APIContext) *issues_model.Issue {
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound("IsErrIssueNotExist", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
issue.Repo = ctx.Repo.Repository
|
||||
@@ -508,11 +500,7 @@ func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Is
|
||||
var err error
|
||||
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, form.Owner, form.Name)
|
||||
if err != nil {
|
||||
if repo_model.IsErrRepoNotExist(err) {
|
||||
ctx.APIErrorNotFound("IsErrRepoNotExist", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
@@ -521,11 +509,7 @@ func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Is
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, repo.ID, form.Index)
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound("IsErrIssueNotExist", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
issue.Repo = repo
|
||||
|
||||
@@ -53,11 +53,7 @@ func LockIssue(ctx *context.APIContext) {
|
||||
reason := web.GetForm(ctx).(*api.LockIssueOption).Reason
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,11 +116,7 @@ func UnlockIssue(ctx *context.APIContext) {
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -53,11 +53,7 @@ func GetIssueCommentReactions(ctx *context.APIContext) {
|
||||
|
||||
comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,11 +186,7 @@ func DeleteIssueCommentReaction(ctx *context.APIContext) {
|
||||
func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOption, isCreateType bool) {
|
||||
comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -71,16 +71,12 @@ func ListTrackedTimes(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) {
|
||||
ctx.APIErrorNotFound("Timetracker is disabled")
|
||||
ctx.APIErrorNotFound("timetracker is disabled")
|
||||
return
|
||||
}
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,11 +178,7 @@ func AddTime(ctx *context.APIContext) {
|
||||
form := web.GetForm(ctx).(*api.AddTimeOption)
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -265,11 +257,7 @@ func ResetIssueTime(ctx *context.APIContext) {
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -338,11 +326,7 @@ func DeleteTime(ctx *context.APIContext) {
|
||||
|
||||
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrIssueNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -357,11 +341,7 @@ func DeleteTime(ctx *context.APIContext) {
|
||||
|
||||
time, err := issues_model.GetTrackedTimeByID(ctx, issue.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if db.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
if time.Deleted {
|
||||
@@ -423,11 +403,7 @@ func ListTrackedTimesByUser(ctx *context.APIContext) {
|
||||
}
|
||||
user, err := user_model.GetUserByName(ctx, ctx.PathParam("timetrackingusername"))
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
|
||||
@@ -68,11 +68,7 @@ func getNote(ctx *context.APIContext, identifier string) {
|
||||
|
||||
commitID, err := ctx.Repo.GitRepo.ConvertToGitID(identifier)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -927,11 +927,7 @@ func MergePullRequest(ctx *context.APIContext) {
|
||||
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -63,11 +63,7 @@ func ListPullReviews(ctx *context.APIContext) {
|
||||
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -389,11 +385,7 @@ func updatePullReviewCommentResolve(ctx *context.APIContext, isResolve bool) {
|
||||
func getPullReviewCommentToResolve(ctx *context.APIContext) *issues_model.Comment {
|
||||
comment, err := issues_model.GetCommentWithRepoID(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrCommentNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetCommentByID", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -510,11 +502,7 @@ func CreatePullReview(ctx *context.APIContext) {
|
||||
opts := web.GetForm(ctx).(*api.CreatePullReviewOptions)
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -737,33 +725,25 @@ func preparePullReviewType(ctx *context.APIContext, pr *issues_model.PullRequest
|
||||
func prepareSingleReview(ctx *context.APIContext) (*issues_model.Review, *issues_model.PullRequest, bool) {
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
review, err := issues_model.GetReviewByID(ctx, ctx.PathParamInt64("id"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrReviewNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetReviewByID", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
// validate the review is for the given PR
|
||||
if review.IssueID != pr.IssueID {
|
||||
ctx.APIErrorNotFound("ReviewNotInPR")
|
||||
ctx.APIErrorNotFound()
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
// make sure that the user has access to this review if it is pending
|
||||
if review.Type == issues_model.ReviewTypePending && review.ReviewerID != ctx.Doer.ID && !ctx.Doer.IsAdmin {
|
||||
ctx.APIErrorNotFound("GetReviewByID")
|
||||
ctx.APIErrorNotFound()
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
@@ -870,7 +850,7 @@ func parseReviewersByNames(ctx *context.APIContext, reviewerNames, teamReviewerN
|
||||
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.APIErrorNotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r))
|
||||
ctx.APIErrorNotFound("user doesn't exist: " + r)
|
||||
return nil, nil
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -886,7 +866,7 @@ func parseReviewersByNames(ctx *context.APIContext, reviewerNames, teamReviewerN
|
||||
teamReviewer, err = organization.GetTeam(ctx, ctx.Repo.Owner.ID, t)
|
||||
if err != nil {
|
||||
if organization.IsErrTeamNotExist(err) {
|
||||
ctx.APIErrorNotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t))
|
||||
ctx.APIErrorNotFound("team doesn't exist: " + t)
|
||||
return nil, nil
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
@@ -902,11 +882,7 @@ func parseReviewersByNames(ctx *context.APIContext, reviewerNames, teamReviewerN
|
||||
func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions, isAdd bool) {
|
||||
pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
|
||||
if err != nil {
|
||||
if issues_model.IsErrPullRequestNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetPullRequestByIndex", err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ func CreateReleaseAttachment(ctx *context.APIContext) {
|
||||
|
||||
// Check if attachments are enabled
|
||||
if !setting.Attachment.Enabled {
|
||||
ctx.APIErrorNotFound("Attachment is not enabled")
|
||||
ctx.APIErrorNotFound("attachment is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -245,11 +245,7 @@ func DeleteWikiPage(ctx *context.APIContext) {
|
||||
wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("pageName"))
|
||||
|
||||
if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil {
|
||||
if err.Error() == "file does not exist" {
|
||||
ctx.APIErrorNotFound(err)
|
||||
return
|
||||
}
|
||||
ctx.APIErrorInternal(err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -474,21 +470,13 @@ func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error)
|
||||
func findWikiRepoCommit(ctx *context.APIContext) (*git.Repository, *git.Commit) {
|
||||
wikiRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo())
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) || err.Error() == "no such file or directory" {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
commit, err := wikiRepo.GetBranchCommit(ctx.Repo.Repository.DefaultWikiBranch)
|
||||
if err != nil {
|
||||
if git.IsErrNotExist(err) {
|
||||
ctx.APIErrorNotFound(err)
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return wikiRepo, nil
|
||||
}
|
||||
return wikiRepo, commit
|
||||
|
||||
@@ -45,7 +45,7 @@ func ListBlocks(ctx *context.APIContext, blocker *user_model.User) {
|
||||
func CheckUserBlock(ctx *context.APIContext, blocker *user_model.User) {
|
||||
blockee, err := user_model.GetUserByName(ctx, ctx.PathParam("username"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func CheckUserBlock(ctx *context.APIContext, blocker *user_model.User) {
|
||||
func BlockUser(ctx *context.APIContext, blocker *user_model.User) {
|
||||
blockee, err := user_model.GetUserByName(ctx, ctx.PathParam("username"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func BlockUser(ctx *context.APIContext, blocker *user_model.User) {
|
||||
func UnblockUser(ctx *context.APIContext, doer, blocker *user_model.User) {
|
||||
blockee, err := user_model.GetUserByName(ctx, ctx.PathParam("username"))
|
||||
if err != nil {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorAuto(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -77,11 +77,7 @@ func getRunnerByID(ctx *context.APIContext, ownerID, repoID, runnerID int64) (*a
|
||||
|
||||
runner, err := actions_model.GetRunnerByID(ctx, runnerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
ctx.APIErrorNotFound("Runner not found")
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
}
|
||||
ctx.APIErrorAuto(err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -135,7 +134,7 @@ func GetGPGKey(ctx *context.APIContext) {
|
||||
// CreateUserGPGKey creates new GPG key to given user by ID.
|
||||
func CreateUserGPGKey(ctx *context.APIContext, form api.CreateGPGKeyOption, uid int64) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
|
||||
ctx.APIErrorNotFound("Not Found", errors.New("gpg keys setting is not allowed to be visited"))
|
||||
ctx.APIErrorNotFound("gpg keys setting is not allowed to be changed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -276,7 +275,7 @@ func DeleteGPGKey(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) {
|
||||
ctx.APIErrorNotFound("Not Found", errors.New("gpg keys setting is not allowed to be visited"))
|
||||
ctx.APIErrorNotFound("gpg keys setting is not allowed to be changed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func GetUserByPathParam(ctx *context.APIContext, name string) *user_model.User {
|
||||
if redirectUserID, err2 := user_model.LookupUserRedirect(ctx, username); err2 == nil {
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, username, redirectUserID)
|
||||
} else {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
ctx.APIErrorNotFound()
|
||||
}
|
||||
} else {
|
||||
ctx.APIErrorInternal(err)
|
||||
|
||||
@@ -6,7 +6,6 @@ package user
|
||||
|
||||
import (
|
||||
std_ctx "context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
asymkey_model "gitea.dev/models/asymkey"
|
||||
@@ -201,7 +200,7 @@ func GetPublicKey(ctx *context.APIContext) {
|
||||
// CreateUserPublicKey creates new public key to given user by ID.
|
||||
func CreateUserPublicKey(ctx *context.APIContext, form api.CreateKeyOption, uid int64) {
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
|
||||
ctx.APIErrorNotFound("Not Found", errors.New("ssh keys setting is not allowed to be visited"))
|
||||
ctx.APIErrorNotFound("ssh keys setting is not allowed to be changed")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -271,7 +270,7 @@ func DeletePublicKey(ctx *context.APIContext) {
|
||||
// "$ref": "#/responses/notFound"
|
||||
|
||||
if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) {
|
||||
ctx.APIErrorNotFound("Not Found", errors.New("ssh keys setting is not allowed to be visited"))
|
||||
ctx.APIErrorNotFound("ssh keys setting is not allowed to be changed")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ func GetInfo(ctx *context.APIContext) {
|
||||
|
||||
if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) {
|
||||
// fake ErrUserNotExist error message to not leak information about existence
|
||||
ctx.APIErrorNotFound("GetUserByName", user_model.ErrUserNotExist{Name: ctx.PathParam("username")})
|
||||
ctx.APIErrorNotFound()
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, convert.ToUser(ctx, ctx.ContextUser, ctx.Doer))
|
||||
|
||||
@@ -214,6 +214,75 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID)
|
||||
}
|
||||
|
||||
// Keep devtest mock runs minimal: use run 10 as a "complex graph" repro.
|
||||
// This combines long durations, parallel roots, and a multi-dependency downstream job
|
||||
// to validate the workflow graph rendering.
|
||||
if runID == 10 {
|
||||
resp.State.Run.WorkflowID = "workflow-devtest-complex"
|
||||
resp.State.Run.Duration = "7h 12m 34s"
|
||||
|
||||
type mj struct {
|
||||
jobID string
|
||||
name string
|
||||
status actions_model.Status
|
||||
duration string
|
||||
needs []string
|
||||
}
|
||||
mockJobs := []mj{
|
||||
{jobID: "job-100", name: "job-100", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
|
||||
{jobID: "job-101", name: "job-101", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"job-100"}},
|
||||
{jobID: "job-102", name: "job-102", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"job-100", "job-101"}},
|
||||
{jobID: "job-103", name: "job-103", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"job-100"}},
|
||||
|
||||
{jobID: "prep-jdk", name: "prep-jdk", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
|
||||
{jobID: "code-analysis", name: "code-analysis", status: actions_model.StatusSuccess, duration: "3s", needs: nil},
|
||||
|
||||
// Matrix expansion (the " (...)" suffix is the heuristic the frontend uses to group rows)
|
||||
{jobID: "matrix-e2e-1-chromium", name: "matrix-e2e (1, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-1-firefox", name: "matrix-e2e (1, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-2-chromium", name: "matrix-e2e (2, chromium)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-3-chromium", name: "matrix-e2e (3, chromium)", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-3-firefox", name: "matrix-e2e (3, firefox)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "matrix-e2e-99-webkit", name: "matrix-e2e (99, webkit)", status: actions_model.StatusSuccess, duration: "2s", needs: []string{"prep-jdk"}},
|
||||
|
||||
{jobID: "unit-test", name: "unit-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "arch-test", name: "arch-test", status: actions_model.StatusSuccess, duration: "3s", needs: []string{"prep-jdk"}},
|
||||
{jobID: "integration-test", name: "integration-test", status: actions_model.StatusSuccess, duration: "4s", needs: []string{"prep-jdk"}},
|
||||
|
||||
{jobID: "build-image", name: "build-image", status: actions_model.StatusSuccess, duration: "3s", needs: []string{
|
||||
"unit-test",
|
||||
"arch-test",
|
||||
"integration-test",
|
||||
"code-analysis",
|
||||
"matrix-e2e-1-chromium",
|
||||
"matrix-e2e-1-firefox",
|
||||
"matrix-e2e-2-chromium",
|
||||
"matrix-e2e-3-chromium",
|
||||
"matrix-e2e-3-firefox",
|
||||
"matrix-e2e-99-webkit",
|
||||
}},
|
||||
}
|
||||
|
||||
resp.State.Run.Jobs = nil
|
||||
for i, j := range mockJobs {
|
||||
id := runID*1000 + int64(i)
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: id,
|
||||
Link: jobLink(id),
|
||||
JobID: j.jobID,
|
||||
Name: j.name,
|
||||
Status: j.status.String(),
|
||||
CanRerun: j.jobID == "job-100",
|
||||
Duration: j.duration,
|
||||
Needs: j.needs,
|
||||
})
|
||||
}
|
||||
|
||||
fillViewRunResponseCurrentJob(ctx, resp)
|
||||
ctx.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
ID: runID * 10,
|
||||
Link: jobLink(runID * 10),
|
||||
@@ -240,7 +309,7 @@ func MockActionsRunsJobs(ctx *context.Context) {
|
||||
Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit",
|
||||
Status: actions_model.StatusFailure.String(),
|
||||
CanRerun: false,
|
||||
Duration: "3h",
|
||||
Duration: "3h35m10s",
|
||||
Needs: []string{"job-100", "job-101"},
|
||||
})
|
||||
resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/util"
|
||||
shared_user "gitea.dev/routers/web/shared/user"
|
||||
actions_service "gitea.dev/services/actions"
|
||||
"gitea.dev/services/context"
|
||||
"gitea.dev/services/convert"
|
||||
|
||||
@@ -208,12 +209,20 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow
|
||||
if !hasJobWithoutNeeds && len(j.Needs()) == 0 {
|
||||
hasJobWithoutNeeds = true
|
||||
}
|
||||
if j.Uses != "" {
|
||||
if _, err := actions_service.ResolveUses(ctx, j.Uses); err != nil {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasJobWithoutNeeds {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
|
||||
}
|
||||
if emptyJobsNumber == len(wf.Jobs) {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
|
||||
if workflow.ErrMsg == "" {
|
||||
if !hasJobWithoutNeeds {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job_without_needs")
|
||||
}
|
||||
if emptyJobsNumber == len(wf.Jobs) {
|
||||
workflow.ErrMsg = ctx.Locale.TrString("actions.runs.no_job")
|
||||
}
|
||||
}
|
||||
workflows = append(workflows, workflow)
|
||||
}
|
||||
@@ -352,7 +361,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
|
||||
return
|
||||
}
|
||||
for _, run := range runs {
|
||||
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) {
|
||||
if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) {
|
||||
continue
|
||||
}
|
||||
jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID)
|
||||
@@ -361,23 +370,31 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo
|
||||
return
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if !job.Status.IsWaiting() {
|
||||
if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) {
|
||||
continue
|
||||
}
|
||||
if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error())
|
||||
break
|
||||
}
|
||||
hasOnlineRunner := false
|
||||
for _, runner := range runners {
|
||||
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
|
||||
hasOnlineRunner = true
|
||||
if job.CallUses != "" {
|
||||
if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error())
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOnlineRunner {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
|
||||
break
|
||||
if job.Status.IsWaiting() {
|
||||
hasOnlineRunner := false
|
||||
for _, runner := range runners {
|
||||
if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) {
|
||||
hasOnlineRunner = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasOnlineRunner {
|
||||
runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ","))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
@@ -15,7 +16,9 @@ import (
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/setting"
|
||||
api "gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/services/convert"
|
||||
@@ -149,10 +152,10 @@ func expandReusableWorkflowCaller(ctx context.Context, run *actions_model.Action
|
||||
return fmt.Errorf("parse caller job %d: %w", caller.ID, err)
|
||||
}
|
||||
|
||||
// 3. Load called-workflow source.
|
||||
ref, err := jobparser.ParseUses(parsedJob.Uses)
|
||||
// 3. Resolve `uses` and load called-workflow source.
|
||||
ref, err := ResolveUses(ctx, parsedJob.Uses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse uses %q: %w", parsedJob.Uses, err)
|
||||
return fmt.Errorf("resolve uses %q: %w", parsedJob.Uses, err)
|
||||
}
|
||||
content, contentSourceRepoID, contentSourceCommitSHA, err := loadReusableWorkflowSource(ctx, run, caller, ref)
|
||||
if err != nil {
|
||||
@@ -340,3 +343,20 @@ func insertCallerChildren(ctx context.Context, run *actions_model.ActionRun, att
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveUses normalizes and parses a reusable workflow `uses:` value.
|
||||
// It first rewrites an absolute URL pointing to this instance into the cross-repo form (rejecting external URLs),
|
||||
// then validates the syntax via jobparser.ParseUses.
|
||||
func ResolveUses(ctx context.Context, uses string) (*jobparser.UsesRef, error) {
|
||||
// Rewrite a local-instance URL to the equivalent cross-repo form "owner/repo/.gitea/workflows/file.yml@ref".
|
||||
if strings.HasPrefix(uses, "http://") || strings.HasPrefix(uses, "https://") {
|
||||
// ParseGiteaSiteURL returns nil for URLs that do not belong to this instance.
|
||||
gsu := httplib.ParseGiteaSiteURL(ctx, uses)
|
||||
if gsu == nil {
|
||||
return nil, fmt.Errorf("unsupported reusable workflow URL %q: an absolute URL must point to this Gitea instance (%s)", uses, setting.AppURL)
|
||||
}
|
||||
// RoutePath is the instance-relative path (AppSubURL already stripped), e.g. "/owner/repo/.gitea/workflows/file.yml@ref".
|
||||
uses = strings.TrimPrefix(gsu.RoutePath, "/")
|
||||
}
|
||||
return jobparser.ParseUses(uses)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -132,3 +135,44 @@ func buildCallerChain(t *testing.T, callerUses ...string) []*actions_model.Actio
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
func TestResolveUses(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.AppURL, "https://gitea.example.com/sub/")()
|
||||
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
|
||||
ctx := t.Context()
|
||||
|
||||
t.Run("LocalForms", func(t *testing.T) {
|
||||
// Same-repo and cross-repo forms are not URLs and are parsed as-is.
|
||||
ref, err := ResolveUses(ctx, "./.gitea/workflows/build.yml")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalSameRepo, Path: ".gitea/workflows/build.yml"}, *ref)
|
||||
|
||||
ref, err = ResolveUses(ctx, "owner/repo/.gitea/workflows/build.yml@v1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/build.yml", Ref: "v1"}, *ref)
|
||||
})
|
||||
|
||||
t.Run("LocalInstanceURL", func(t *testing.T) {
|
||||
// An absolute URL on this instance (incl. AppSubURL) resolves to the equivalent cross-repo ref.
|
||||
ref, err := ResolveUses(ctx, "https://gitea.example.com/sub/owner/repo/.gitea/workflows/ci.yml@refs/heads/main")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jobparser.UsesRef{Kind: jobparser.UsesKindLocalCrossRepo, Owner: "owner", Repo: "repo", Path: ".gitea/workflows/ci.yml", Ref: "refs/heads/main"}, *ref)
|
||||
})
|
||||
|
||||
t.Run("InvalidSyntax", func(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"owner/.gitea/workflows/foo.yml", // missing repo segment
|
||||
"owner/repo/.gitea/workflows/foo.yml", // missing @ref
|
||||
"https://gitea.example.com/sub/repo/.gitea/workflows/ci.yml@refs/heads/main", // local absolute URL but missing owner
|
||||
"not a valid uses at all",
|
||||
} {
|
||||
_, err := ResolveUses(ctx, in)
|
||||
require.Error(t, err, "in = %s", in)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ForeignURL", func(t *testing.T) {
|
||||
_, err := ResolveUses(ctx, "https://other.gitea-example.com/owner/repo/.gitea/workflows/ci.yaml@v1")
|
||||
assert.ErrorContains(t, err, "must point to this Gitea instance")
|
||||
})
|
||||
}
|
||||
|
||||
+4
-20
@@ -138,26 +138,10 @@ func (ctx *APIContext) apiErrorInternal(skip int, err error) {
|
||||
}
|
||||
|
||||
// APIErrorNotFound handles 404s for APIContext
|
||||
// String will replace message, errors will be added to a slice
|
||||
func (ctx *APIContext) APIErrorNotFound(objs ...any) {
|
||||
var message string
|
||||
var errs []string
|
||||
for _, obj := range objs {
|
||||
// Ignore nil
|
||||
if obj == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err, ok := obj.(error); ok {
|
||||
errs = append(errs, err.Error())
|
||||
} else {
|
||||
message = obj.(string)
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusNotFound, map[string]any{
|
||||
"message": util.IfZero(message, "not found"), // do not use locale in API
|
||||
"url": setting.API.SwaggerURL,
|
||||
"errors": errs,
|
||||
func (ctx *APIContext) APIErrorNotFound(msg ...string) {
|
||||
ctx.JSON(http.StatusNotFound, APIError{
|
||||
Message: util.OptionalArg(msg, "not found"),
|
||||
URL: setting.API.SwaggerURL,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
org_model "gitea.dev/models/organization"
|
||||
@@ -26,6 +27,10 @@ type ReviewRequestNotifier struct {
|
||||
|
||||
var codeOwnerFiles = []string{"CODEOWNERS", "docs/CODEOWNERS", ".gitea/CODEOWNERS"}
|
||||
|
||||
// codeOwnerMatchBudget caps the total wall-clock time spent evaluating all
|
||||
// CODEOWNERS rules against all changed files for a single PR.
|
||||
const codeOwnerMatchBudget = 2 * time.Second
|
||||
|
||||
func IsCodeOwnerFile(f string) bool {
|
||||
return slices.Contains(codeOwnerFiles, f)
|
||||
}
|
||||
@@ -93,8 +98,17 @@ func PullRequestCodeOwnersReview(ctx context.Context, pr *issues_model.PullReque
|
||||
|
||||
uniqUsers := make(map[int64]*user_model.User)
|
||||
uniqTeams := make(map[string]*org_model.Team)
|
||||
// Bound the total time spent matching rules×files. The per-rule MatchTimeout
|
||||
// only caps a single match; without an aggregate budget a crafted CODEOWNERS
|
||||
// plus a PR touching many files could still exhaust CPU inside this loop.
|
||||
matchDeadline := time.Now().Add(codeOwnerMatchBudget)
|
||||
ruleLoop:
|
||||
for _, rule := range rules {
|
||||
for _, f := range changedFiles {
|
||||
if time.Now().After(matchDeadline) {
|
||||
log.Warn("CODEOWNERS matching for PR %s#%d exceeded its time budget; some rules were not evaluated", pr.BaseRepo.FullName(), pr.ID)
|
||||
break ruleLoop
|
||||
}
|
||||
shouldMatch := !rule.Negative
|
||||
matched, _ := rule.Rule.MatchString(f) // err only happens when timeouts, any error can be considered as not matched
|
||||
if matched == shouldMatch {
|
||||
|
||||
@@ -7,7 +7,6 @@ package wiki
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
repo_module "gitea.dev/modules/repository"
|
||||
"gitea.dev/modules/util"
|
||||
asymkey_service "gitea.dev/services/asymkey"
|
||||
repo_service "gitea.dev/services/repository"
|
||||
)
|
||||
@@ -304,7 +304,7 @@ func DeleteWikiPage(ctx context.Context, doer *user_model.User, repo *repo_model
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return os.ErrNotExist
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
// FIXME: The wiki doesn't have lfs support at present - if this changes need to check attributes here
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content">
|
||||
<div class="flex-text-block tw-justify-center tw-gap-5">
|
||||
<a href="/devtest/repo-action-view/runs/10">Run:CanCancel</a>
|
||||
<a href="/devtest/repo-action-view/runs/20">Run:CanApprove</a>
|
||||
<a href="/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
|
||||
<a href="/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
|
||||
<a href="/devtest/repo-action-view/runs/40">Run:ReusableCaller</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/10">Run:CanCancel</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/20">Run:CanApprove</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/30">Run:CanRerunLatest</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/10/attempts/2">Run:PreviousAttempt</a>
|
||||
<a href="{{AppSubUrl}}/devtest/repo-action-view/runs/40">Run:ReusableCaller</a>
|
||||
</div>
|
||||
{{template "repo/actions/view_component" (dict
|
||||
"JobID" (or .JobID 0)
|
||||
|
||||
@@ -151,9 +151,7 @@ func testUnknownOrganization(t *testing.T) {
|
||||
|
||||
req := NewRequest(t, "GET", "/api/v1/users/user1/orgs/unknown/permissions").
|
||||
AddTokenAuth(token)
|
||||
resp := MakeRequest(t, req, http.StatusNotFound)
|
||||
apiError := DecodeJSON(t, resp, &api.APIError{})
|
||||
assert.Equal(t, "GetUserByName", apiError.Message)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func testHiddenMemberPermissionsForbidden(t *testing.T) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import ActionStatusIcon from './ActionStatusIcon.vue';
|
||||
import WorkflowGraph from './WorkflowGraph.vue';
|
||||
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
|
||||
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
@@ -13,7 +12,6 @@ import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
import {
|
||||
type ActionRunViewStore,
|
||||
collectCallerChildJobs,
|
||||
createLogLineMessage,
|
||||
type LogLine,
|
||||
type LogLineCommand,
|
||||
@@ -118,14 +116,11 @@ const currentJob = ref<CurrentJob>({
|
||||
const stepsContainer = ref<HTMLElement | null>(null);
|
||||
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
|
||||
|
||||
// Reusable workflow caller view: when the selected job is a caller node, the right pane
|
||||
// shows the children list rather than step logs (callers don't run on a runner).
|
||||
// Reusable workflow caller view: the right pane shows just the header (name + uses path +
|
||||
// status). Callers don't run on a runner, and the dependency graph for their children lives
|
||||
// in the run summary's WorkflowGraph, not here — matching GitHub Actions.
|
||||
const selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
|
||||
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
|
||||
const callerChildJobs = computed<ActionsJob[]>(() => {
|
||||
if (!isCallerJob.value) return [];
|
||||
return collectCallerChildJobs(run.value.jobs || [], props.jobId);
|
||||
});
|
||||
|
||||
watch(optionAlwaysAutoScroll, () => {
|
||||
saveLocaleStorageOptions();
|
||||
@@ -477,20 +472,6 @@ async function hashChangeListener() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Caller (reusable workflow) view: render the direct children's dependency graph,
|
||||
mirroring the run summary's WorkflowGraph but scoped to this caller's subtree.
|
||||
The caller's name + uses path + status all live in job-info-header above. -->
|
||||
<div class="caller-children-container" v-if="isCallerJob">
|
||||
<WorkflowGraph
|
||||
v-if="callerChildJobs.length > 0"
|
||||
:store="store"
|
||||
:jobs="callerChildJobs"
|
||||
:run-link="run.link"
|
||||
:workflow-id="`${run.workflowID}#caller-${props.jobId}`"
|
||||
:locale="locale"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
|
||||
<div class="job-step-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length">
|
||||
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
|
||||
@@ -578,8 +559,7 @@ async function hashChangeListener() {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.job-info-header:has(+ .job-step-container),
|
||||
.job-info-header:has(+ .caller-children-container) {
|
||||
.job-info-header:has(+ .job-step-container) {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
@@ -613,14 +593,6 @@ async function hashChangeListener() {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.caller-children-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--color-console-border);
|
||||
color: var(--color-console-fg);
|
||||
}
|
||||
|
||||
.job-step-container {
|
||||
max-height: 100%;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
|
||||
@@ -104,12 +104,6 @@ export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJ
|
||||
return childrenByParent;
|
||||
}
|
||||
|
||||
// collectCallerChildJobs returns the direct children of a caller job.
|
||||
export function collectCallerChildJobs(jobs: ActionsJob[], callerJobID: number): ActionsJob[] {
|
||||
if (!callerJobID) return [];
|
||||
return buildJobsByParentJobID(jobs).get(callerJobID) || [];
|
||||
}
|
||||
|
||||
export function createEmptyActionsRun(): ActionsRun {
|
||||
return {
|
||||
repoId: 0,
|
||||
|
||||
@@ -26,7 +26,6 @@ const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
|
||||
type JobListItem = {
|
||||
job: ActionsJob;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
};
|
||||
|
||||
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
|
||||
@@ -71,9 +70,8 @@ const visibleJobListItems = computed<JobListItem[]>(() => {
|
||||
while (stack.length > 0) {
|
||||
const {job, depth} = stack.pop()!;
|
||||
const children = childrenByParent.get(job.id) || [];
|
||||
const hasChildren = children.length > 0;
|
||||
result.push({job, depth, hasChildren});
|
||||
if (hasChildren && isJobCollapsed(job.id)) continue;
|
||||
result.push({job, depth});
|
||||
if (children.length > 0 && isJobCollapsed(job.id)) continue;
|
||||
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
|
||||
}
|
||||
return result;
|
||||
@@ -216,24 +214,28 @@ async function deleteArtifact(name: string) {
|
||||
v-for="item in visibleJobListItems"
|
||||
:key="item.job.id"
|
||||
>
|
||||
<a class="tw-contents silenced" :href="item.job.link">
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
</a>
|
||||
<!-- Callers have no log page of their own; the whole row toggles expansion
|
||||
(matches GitHub Actions, where caller rows are not navigation targets). -->
|
||||
<button
|
||||
v-if="item.hasChildren"
|
||||
v-if="item.job.isReusableCaller"
|
||||
type="button"
|
||||
class="job-brief-toggle"
|
||||
:class="{'collapsed': isJobCollapsed(item.job.id)}"
|
||||
class="tw-contents caller-row-toggle"
|
||||
@click="toggleExpandedJob(item.job.id)"
|
||||
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
|
||||
:aria-expanded="!isJobCollapsed(item.job.id)"
|
||||
>
|
||||
<SvgIcon name="octicon-chevron-down" :size="14"/>
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
<SvgIcon name="octicon-chevron-down" :size="14" class="job-brief-toggle-icon" :class="{'collapsed': isJobCollapsed(item.job.id)}"/>
|
||||
</button>
|
||||
<a v-else class="tw-contents silenced" :href="item.job.link">
|
||||
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
|
||||
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
|
||||
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
|
||||
<span class="job-duration">{{ item.job.duration }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +260,7 @@ async function deleteArtifact(name: string) {
|
||||
<SvgIcon name="octicon-trash"/>
|
||||
</a>
|
||||
</template>
|
||||
<span v-else class="flex-text-block tw-flex-1 tw-text-text-light-2">
|
||||
<span v-else class="flex-text-block tw-flex-1 tw-min-w-0 tw-text-text-light-2">
|
||||
<SvgIcon name="octicon-file-removed"/>
|
||||
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
|
||||
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
|
||||
@@ -406,23 +408,23 @@ async function deleteArtifact(name: string) {
|
||||
background-color: var(--color-active);
|
||||
}
|
||||
|
||||
.job-brief-toggle {
|
||||
.caller-row-toggle {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.job-brief-toggle-icon {
|
||||
flex-shrink: 0;
|
||||
/* the icon is always chevron-down; flip to chevron-up when expanded */
|
||||
transition: transform 0.15s ease;
|
||||
/* sit right after the job name; rerun/duration float to the right via auto-margin */
|
||||
/* sit between name and duration; duration uses order:2 with margin-left:auto to float right */
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.job-brief-toggle:not(.collapsed) {
|
||||
.job-brief-toggle-icon:not(.collapsed) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import {computeGraphHighlightState, computeJobLevels, createWorkflowGraphModel, matrixKeyFromJobName} from './WorkflowGraph.utils.ts';
|
||||
import type {ActionsJob} from '../modules/gitea-actions.ts';
|
||||
|
||||
const mockJobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'job-100', name: 'job-100', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 2, link: '', jobId: 'job-101', name: 'job-101', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['job-100']},
|
||||
{id: 3, link: '', jobId: 'job-102', name: 'job-102', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-101']},
|
||||
{id: 4, link: '', jobId: 'job-103', name: 'job-103', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100']},
|
||||
{id: 5, link: '', jobId: 'prep-jdk', name: 'prep-jdk', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 6, link: '', jobId: 'code-analysis', name: 'code-analysis', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 7, link: '', jobId: 'matrix-e2e-1-chromium', name: 'matrix-e2e (1, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 8, link: '', jobId: 'matrix-e2e-1-firefox', name: 'matrix-e2e (1, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 9, link: '', jobId: 'matrix-e2e-2-chromium', name: 'matrix-e2e (2, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 10, link: '', jobId: 'matrix-e2e-3-chromium', name: 'matrix-e2e (3, chromium)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 11, link: '', jobId: 'matrix-e2e-3-firefox', name: 'matrix-e2e (3, firefox)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 12, link: '', jobId: 'matrix-e2e-99-webkit', name: 'matrix-e2e (99, webkit)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['job-100', 'prep-jdk', 'code-analysis']},
|
||||
{id: 13, link: '', jobId: 'unit-test', name: 'unit-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
|
||||
{id: 14, link: '', jobId: 'arch-test', name: 'arch-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['prep-jdk', 'code-analysis']},
|
||||
{id: 15, link: '', jobId: 'integration-test', name: 'integration-test', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['prep-jdk', 'code-analysis']},
|
||||
{id: 16, link: '', jobId: 'build-image', name: 'build-image', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: [
|
||||
'unit-test',
|
||||
'arch-test',
|
||||
'integration-test',
|
||||
'matrix-e2e-1-chromium',
|
||||
'matrix-e2e-1-firefox',
|
||||
'matrix-e2e-2-chromium',
|
||||
'matrix-e2e-3-chromium',
|
||||
'matrix-e2e-3-firefox',
|
||||
'matrix-e2e-99-webkit',
|
||||
]},
|
||||
];
|
||||
|
||||
const verifyDeployJobs: ActionsJob[] = [
|
||||
{id: 101, link: '', jobId: 'seed-dev', name: 'seed-dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s'},
|
||||
{id: 102, link: '', jobId: 'seed-qa', name: 'seed-qa', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s'},
|
||||
{id: 103, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['seed-dev']},
|
||||
{id: 104, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['seed-qa']},
|
||||
{id: 105, link: '', jobId: 'deploy', name: 'Deploy', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '', needs: ['verify-dev', 'verify-qa']},
|
||||
];
|
||||
|
||||
// Multi-level pipeline with two matrices and a leaf with two parents.
|
||||
const wfTest1Jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'init', name: 'Initialize Pipeline', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
||||
{id: 2, link: '', jobId: 'lint-frontend', name: 'Lint Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
|
||||
{id: 3, link: '', jobId: 'lint-backend', name: 'Lint Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['init']},
|
||||
{id: 4, link: '', jobId: 'build-frontend', name: 'Build Frontend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['lint-frontend']},
|
||||
{id: 5, link: '', jobId: 'build-backend', name: 'Build Backend', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['lint-backend']},
|
||||
{id: 6, link: '', jobId: 'tu-api-t', name: 'Unit Tests (api, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 7, link: '', jobId: 'tu-api-f', name: 'Unit Tests (api, false)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 8, link: '', jobId: 'tu-svc-t', name: 'Unit Tests (service, true)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['build-frontend', 'build-backend']},
|
||||
{id: 9, link: '', jobId: 'test-integration', name: 'Integration Tests', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '6s', needs: ['build-backend']},
|
||||
{id: 10, link: '', jobId: 'te-c-d', name: 'E2E Tests (chrome, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
|
||||
{id: 11, link: '', jobId: 'te-c-m', name: 'E2E Tests (chrome, mobile)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
|
||||
{id: 12, link: '', jobId: 'te-f-d', name: 'E2E Tests (firefox, desktop)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '4s', needs: ['build-frontend', 'tu-api-t', 'tu-api-f', 'tu-svc-t']},
|
||||
{id: 13, link: '', jobId: 'bundle-app', name: 'Bundle Application', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['tu-api-t', 'tu-api-f', 'tu-svc-t', 'test-integration', 'te-c-d', 'te-c-m', 'te-f-d']},
|
||||
{id: 14, link: '', jobId: 'deploy-dev', name: 'Deploy to Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
|
||||
{id: 15, link: '', jobId: 'deploy-qa', name: 'Deploy to QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '3s', needs: ['bundle-app']},
|
||||
{id: 16, link: '', jobId: 'verify-dev', name: 'Verify Dev', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-dev']},
|
||||
{id: 17, link: '', jobId: 'verify-qa', name: 'Verify QA', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-qa']},
|
||||
{id: 18, link: '', jobId: 'deploy-prod', name: 'Deploy to Production', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '5s', needs: ['verify-dev', 'verify-qa']},
|
||||
{id: 19, link: '', jobId: 'post-deploy-checks', name: 'Post-Deploy Checks', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '2s', needs: ['deploy-prod']},
|
||||
];
|
||||
|
||||
test('matrix key heuristic strips trailing parameter list', () => {
|
||||
expect(matrixKeyFromJobName('matrix-e2e (1, chromium)')).toBe('matrix-e2e');
|
||||
expect(matrixKeyFromJobName('plain-job')).toBeNull();
|
||||
});
|
||||
|
||||
test('computeJobLevels keeps stable topological levels', () => {
|
||||
const levels = computeJobLevels(mockJobs);
|
||||
expect(levels.get('job-100')).toBe(0);
|
||||
expect(levels.get('job-101')).toBe(1);
|
||||
expect(levels.get('job-102')).toBe(2);
|
||||
expect(levels.get('build-image')).toBe(2);
|
||||
});
|
||||
|
||||
test('graph model collapses matrix and groups jobs that share parents and children', () => {
|
||||
const graph = createWorkflowGraphModel(mockJobs);
|
||||
|
||||
expect(graph.nodes.find((n) => n.type === 'matrix')?.jobs).toHaveLength(6);
|
||||
const groupJobIds = graph.nodes.filter((n) => n.type === 'group').map((g) => g.jobs.map((j) => j.jobId));
|
||||
expect(groupJobIds).toEqual(expect.arrayContaining([
|
||||
['prep-jdk', 'code-analysis'],
|
||||
['unit-test', 'arch-test', 'integration-test'],
|
||||
]));
|
||||
});
|
||||
|
||||
test('expanded matrix height includes summary and toggle rows', () => {
|
||||
const collapsed = createWorkflowGraphModel(mockJobs);
|
||||
const expanded = createWorkflowGraphModel(mockJobs, new Set(['matrix-e2e']));
|
||||
const collapsedMatrix = collapsed.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
||||
const expandedMatrix = expanded.nodes.find((n) => n.id === 'matrix:matrix-e2e');
|
||||
|
||||
expect(collapsedMatrix?.displayHeight).toBeLessThan(expandedMatrix?.displayHeight ?? 0);
|
||||
// 6 jobs * 26 row height + 24 header + 6 pad * 2 = 192
|
||||
expect(expandedMatrix?.displayHeight).toBe(192);
|
||||
});
|
||||
|
||||
test('every dependency is rendered as one routed edge', () => {
|
||||
const graph = createWorkflowGraphModel(mockJobs);
|
||||
const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
|
||||
const testGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'unit-test'))!;
|
||||
const expectedKeys = [
|
||||
`${rootGroup.id}->matrix:matrix-e2e`,
|
||||
`${rootGroup.id}->${testGroup.id}`,
|
||||
];
|
||||
const keys = new Set(graph.routedEdges.map((e) => e.key));
|
||||
for (const k of expectedKeys) expect(keys.has(k)).toBe(true);
|
||||
});
|
||||
|
||||
test('same-row edge collapses to a single horizontal line', () => {
|
||||
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
||||
const verifyDevEdge = graph.routedEdges.find((e) => e.fromId === 'job:101' && e.toId === 'job:103');
|
||||
const verifyQaEdge = graph.routedEdges.find((e) => e.fromId === 'job:102' && e.toId === 'job:104');
|
||||
expect(verifyDevEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
|
||||
expect(verifyQaEdge?.path).toMatch(/^M [\d.]+ [\d.]+ H [\d.]+$/);
|
||||
});
|
||||
|
||||
test('different-row edge uses cubic bezier curve', () => {
|
||||
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
||||
const deployLowerEdge = graph.routedEdges.find((e) => e.fromId === 'job:104' && e.toId === 'job:105');
|
||||
expect(deployLowerEdge?.path).toContain(' C ');
|
||||
});
|
||||
|
||||
test('multi-level pipeline with two matrices and a converging leaf renders without errors', () => {
|
||||
const graph = createWorkflowGraphModel(wfTest1Jobs);
|
||||
const matrices = graph.nodes.filter((n) => n.type === 'matrix');
|
||||
expect(matrices.map((n) => n.matrixKey).sort()).toEqual(['E2E Tests', 'Unit Tests']);
|
||||
|
||||
const deployProd = graph.nodes.find((n) => n.id === 'job:18');
|
||||
const verifyDev = graph.nodes.find((n) => n.id === 'job:16');
|
||||
const verifyQa = graph.nodes.find((n) => n.id === 'job:17');
|
||||
expect(verifyDev?.level).toBe(verifyQa?.level);
|
||||
expect(deployProd?.level).toBe((verifyDev?.level ?? 0) + 1);
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
expect(Number.isFinite(node.x)).toBe(true);
|
||||
expect(Number.isFinite(node.y)).toBe(true);
|
||||
expect(node.x).toBeGreaterThanOrEqual(0);
|
||||
expect(node.y).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
for (const edge of graph.routedEdges) {
|
||||
expect(edge.path).not.toMatch(/NaN|undefined|Infinity/);
|
||||
}
|
||||
});
|
||||
|
||||
test('reusable callers with identical dependency signature are kept as separate nodes', () => {
|
||||
const jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'prepare', name: 'prepare', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '30s'},
|
||||
{id: 2, link: '', jobId: 'local_caller', name: 'local caller', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '5m', needs: ['prepare'], callUses: './.gitea/workflows/lib.yml'},
|
||||
{id: 3, link: '', jobId: 'cross_caller', name: 'cross-repo caller', status: 'waiting', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '0s', needs: ['prepare'], callUses: 'user2/lib/.gitea/workflows/ext.yml@main'},
|
||||
{id: 4, link: '', jobId: 'final', name: 'final', status: 'blocked', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '0s', needs: ['local_caller', 'cross_caller']},
|
||||
];
|
||||
const graph = createWorkflowGraphModel(jobs);
|
||||
expect(graph.nodes.find((n) => n.type === 'group')).toBeUndefined();
|
||||
expect(graph.nodes.find((n) => n.id === 'job:2')?.name).toBe('local caller');
|
||||
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('cross-repo caller');
|
||||
});
|
||||
|
||||
test('reusable caller with matrix-pattern name does not get absorbed into a sibling matrix node', () => {
|
||||
const jobs: ActionsJob[] = [
|
||||
{id: 1, link: '', jobId: 'deploy_dev', name: 'deploy (dev)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
||||
{id: 2, link: '', jobId: 'deploy_qa', name: 'deploy (qa)', status: 'success', canRerun: false, isReusableCaller: false, parentJobID: 0, duration: '1s'},
|
||||
{id: 3, link: '', jobId: 'deploy_staging', name: 'deploy (staging)', status: 'running', canRerun: false, isReusableCaller: true, parentJobID: 0, duration: '2s', callUses: './.gitea/workflows/deploy.yml'},
|
||||
];
|
||||
const graph = createWorkflowGraphModel(jobs);
|
||||
expect(graph.nodes.find((n) => n.id === 'job:3')?.name).toBe('deploy (staging)');
|
||||
const matrixNode = graph.nodes.find((n) => n.type === 'matrix');
|
||||
expect(matrixNode?.jobs.map((j) => j.id).sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test('directed highlight state covers ancestors and descendants of the hovered node', () => {
|
||||
const graph = createWorkflowGraphModel(mockJobs);
|
||||
const rootGroup = graph.nodes.find((n) => n.type === 'group' && n.jobs.some((j) => j.jobId === 'prep-jdk'))!;
|
||||
|
||||
const highlight = computeGraphHighlightState(rootGroup.id, graph.adjacency);
|
||||
expect(highlight.nodeIds.has('matrix:matrix-e2e')).toBe(true);
|
||||
expect(highlight.nodeIds.has('job:16')).toBe(true);
|
||||
expect(highlight.edgeKeys.has(`${rootGroup.id}->matrix:matrix-e2e`)).toBe(true);
|
||||
});
|
||||
|
||||
test('directed highlight state for converging graph excludes sibling branch when hovering parent', () => {
|
||||
const graph = createWorkflowGraphModel(verifyDeployJobs);
|
||||
|
||||
const parentHighlight = computeGraphHighlightState('job:103', graph.adjacency);
|
||||
expect(parentHighlight.nodeIds.has('job:101')).toBe(true);
|
||||
expect(parentHighlight.nodeIds.has('job:105')).toBe(true);
|
||||
expect(parentHighlight.nodeIds.has('job:104')).toBe(false);
|
||||
expect(parentHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
|
||||
expect(parentHighlight.edgeKeys.has('job:104->job:105')).toBe(false);
|
||||
|
||||
const sinkHighlight = computeGraphHighlightState('job:105', graph.adjacency);
|
||||
expect(sinkHighlight.nodeIds.has('job:103')).toBe(true);
|
||||
expect(sinkHighlight.nodeIds.has('job:104')).toBe(true);
|
||||
expect(sinkHighlight.edgeKeys.has('job:103->job:105')).toBe(true);
|
||||
expect(sinkHighlight.edgeKeys.has('job:104->job:105')).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
|
||||
export type GraphNodeType = 'job' | 'matrix' | 'group';
|
||||
|
||||
export type GraphNode = {
|
||||
id: string;
|
||||
type: GraphNodeType;
|
||||
name: string;
|
||||
status: ActionsStatus;
|
||||
duration: string;
|
||||
x: number;
|
||||
y: number;
|
||||
level: number;
|
||||
displayHeight: number;
|
||||
jobs: ActionsJob[];
|
||||
matrixKey?: string;
|
||||
};
|
||||
|
||||
export type Edge = {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type RoutedEdge = Edge & {
|
||||
path: string;
|
||||
fromNode: GraphNode;
|
||||
toNode: GraphNode;
|
||||
};
|
||||
|
||||
export type SharedSegment = {
|
||||
key: string;
|
||||
edgeKeys: string[];
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type GraphHighlightState = {
|
||||
nodeIds: Set<string>;
|
||||
edgeKeys: Set<string>;
|
||||
};
|
||||
|
||||
export type WorkflowGraphLayoutOptions = {
|
||||
margin: number;
|
||||
nodeWidth: number;
|
||||
nodeHeight: number;
|
||||
columnGap: number;
|
||||
laneGap: number;
|
||||
groupRowHeight: number;
|
||||
groupPadY: number;
|
||||
matrixCollapsedHeight: number;
|
||||
matrixHeaderHeight: number;
|
||||
matrixRowHeight: number;
|
||||
matrixPadY: number;
|
||||
};
|
||||
|
||||
export type WorkflowGraphModel = {
|
||||
nodes: GraphNode[];
|
||||
edges: Edge[];
|
||||
routedEdges: RoutedEdge[];
|
||||
sharedSegments: SharedSegment[];
|
||||
adjacency: NodeAdjacency;
|
||||
};
|
||||
|
||||
export type NodeAdjacency = {
|
||||
incomingByNodeId: Map<string, string[]>;
|
||||
outgoingByNodeId: Map<string, string[]>;
|
||||
};
|
||||
|
||||
const defaultLayoutOptions: WorkflowGraphLayoutOptions = {
|
||||
margin: 24,
|
||||
nodeWidth: 220,
|
||||
nodeHeight: 40,
|
||||
columnGap: 96,
|
||||
laneGap: 32,
|
||||
groupRowHeight: 28,
|
||||
groupPadY: 8,
|
||||
matrixCollapsedHeight: 78,
|
||||
matrixHeaderHeight: 24,
|
||||
matrixRowHeight: 26,
|
||||
matrixPadY: 6,
|
||||
};
|
||||
|
||||
function canonicalKey(ids: Iterable<string>): string {
|
||||
return Array.from(ids).sort().join('');
|
||||
}
|
||||
|
||||
function graphIdForJob(job: ActionsJob): string {
|
||||
return `job:${job.id}`;
|
||||
}
|
||||
|
||||
export function matrixKeyFromJobName(name: string): string | null {
|
||||
const idx = name.indexOf(' (');
|
||||
if (idx === -1) return null;
|
||||
return name.slice(0, idx).trim() || null;
|
||||
}
|
||||
|
||||
export function boxBottom(node: GraphNode): number {
|
||||
return node.y + node.displayHeight;
|
||||
}
|
||||
|
||||
export function boxCenterY(node: GraphNode): number {
|
||||
return node.y + node.displayHeight / 2;
|
||||
}
|
||||
|
||||
function matrixPanelHeight(rowCount: number, expanded: boolean, options: WorkflowGraphLayoutOptions): number {
|
||||
if (rowCount <= 0) return options.nodeHeight;
|
||||
if (!expanded) return options.matrixCollapsedHeight;
|
||||
return options.matrixHeaderHeight + rowCount * options.matrixRowHeight + options.matrixPadY * 2;
|
||||
}
|
||||
|
||||
function groupPanelHeight(rowCount: number, options: WorkflowGraphLayoutOptions): number {
|
||||
return rowCount * options.groupRowHeight + options.groupPadY * 2;
|
||||
}
|
||||
|
||||
function compareStatusWorstFirst(a: ActionsStatus, b: ActionsStatus): number {
|
||||
const rank = (s: ActionsStatus) => {
|
||||
if (s === 'failure') return 0;
|
||||
if (s === 'cancelled') return 1;
|
||||
if (s === 'running') return 2;
|
||||
if (s === 'waiting') return 3;
|
||||
if (s === 'blocked') return 4;
|
||||
if (s === 'success') return 5;
|
||||
if (s === 'skipped') return 6;
|
||||
return 7;
|
||||
};
|
||||
return rank(a) - rank(b);
|
||||
}
|
||||
|
||||
function aggregateStatus(children: ActionsJob[]): ActionsStatus {
|
||||
return children.map((c) => c.status).slice().sort(compareStatusWorstFirst)[0] ?? 'unknown';
|
||||
}
|
||||
|
||||
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
|
||||
const directNeedsByJobId = new Map<string, string[]>();
|
||||
const dependentsByJobId = new Map<string, Set<string>>();
|
||||
|
||||
for (const job of jobs) {
|
||||
const needs = job.needs || [];
|
||||
directNeedsByJobId.set(job.jobId, needs);
|
||||
for (const need of needs) {
|
||||
if (!dependentsByJobId.has(need)) dependentsByJobId.set(need, new Set());
|
||||
dependentsByJobId.get(need)!.add(job.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
const reachabilityCache = new Map<string, boolean>();
|
||||
function canReach(fromJobId: string, toJobId: string): boolean {
|
||||
const cacheKey = `${fromJobId}->${toJobId}`;
|
||||
if (reachabilityCache.has(cacheKey)) return reachabilityCache.get(cacheKey)!;
|
||||
const visited = new Set<string>();
|
||||
const stack = Array.from(dependentsByJobId.get(fromJobId) || []);
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
if (current === toJobId) {
|
||||
reachabilityCache.set(cacheKey, true);
|
||||
return true;
|
||||
}
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
stack.push(...(dependentsByJobId.get(current) || []));
|
||||
}
|
||||
reachabilityCache.set(cacheKey, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const reducedNeedsByJobId = new Map<string, string[]>();
|
||||
for (const [jobId, needs] of directNeedsByJobId) {
|
||||
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
|
||||
return !needs.some((other) => other !== need && canReach(need, other));
|
||||
}));
|
||||
}
|
||||
return reducedNeedsByJobId;
|
||||
}
|
||||
|
||||
export function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
|
||||
const jobMap = new Map<string, ActionsJob>();
|
||||
for (const job of jobs) {
|
||||
jobMap.set(job.name, job);
|
||||
if (job.jobId) jobMap.set(job.jobId, job);
|
||||
}
|
||||
|
||||
const levels = new Map<string, number>();
|
||||
const visited = new Set<string>();
|
||||
const recursionStack = new Set<string>();
|
||||
|
||||
function dfs(jobNameOrId: string): number {
|
||||
if (recursionStack.has(jobNameOrId)) return 0;
|
||||
if (visited.has(jobNameOrId)) return levels.get(jobNameOrId) ?? 0;
|
||||
recursionStack.add(jobNameOrId);
|
||||
visited.add(jobNameOrId);
|
||||
|
||||
const job = jobMap.get(jobNameOrId);
|
||||
if (!job) {
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return 0;
|
||||
}
|
||||
if (!job.needs?.length) {
|
||||
levels.set(job.jobId, 0);
|
||||
if (job.jobId !== job.name) levels.set(job.name, 0);
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
let maxLevel = -1;
|
||||
for (const need of job.needs) {
|
||||
if (!jobMap.has(need)) continue;
|
||||
maxLevel = Math.max(maxLevel, dfs(need));
|
||||
}
|
||||
const level = maxLevel + 1;
|
||||
levels.set(job.name, level);
|
||||
levels.set(job.jobId, level);
|
||||
recursionStack.delete(jobNameOrId);
|
||||
return level;
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
if (!visited.has(job.jobId)) dfs(job.jobId);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
export function computeGraphHighlightState(hoveredId: string | null, adjacency: NodeAdjacency): GraphHighlightState {
|
||||
if (!hoveredId) return {nodeIds: new Set(), edgeKeys: new Set()};
|
||||
const {incomingByNodeId, outgoingByNodeId} = adjacency;
|
||||
|
||||
const edgeKeys = new Set<string>();
|
||||
const collect = (startId: string, adj: Map<string, string[]>, edgeKeyForward: boolean): Set<string> => {
|
||||
const seen = new Set<string>();
|
||||
const queue = [startId];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
for (const next of adj.get(current) || []) {
|
||||
edgeKeys.add(edgeKeyForward ? `${current}->${next}` : `${next}->${current}`);
|
||||
if (!seen.has(next)) queue.push(next);
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
const ancestors = collect(hoveredId, incomingByNodeId, false);
|
||||
const descendants = collect(hoveredId, outgoingByNodeId, true);
|
||||
return {nodeIds: new Set([...ancestors, ...descendants]), edgeKeys};
|
||||
}
|
||||
|
||||
type VisualGraphBuild = {
|
||||
nodes: GraphNode[];
|
||||
edges: Edge[];
|
||||
};
|
||||
|
||||
function buildVisualGraph(
|
||||
jobs: ActionsJob[],
|
||||
expandedMatrixKeys: ReadonlySet<string>,
|
||||
options: WorkflowGraphLayoutOptions,
|
||||
): VisualGraphBuild {
|
||||
const jobsByJobId = new Map<string, ActionsJob[]>();
|
||||
const jobIndexById = new Map<number, number>();
|
||||
for (const [index, job] of jobs.entries()) {
|
||||
jobIndexById.set(job.id, index);
|
||||
if (!jobsByJobId.has(job.jobId)) jobsByJobId.set(job.jobId, []);
|
||||
jobsByJobId.get(job.jobId)!.push(job);
|
||||
}
|
||||
|
||||
const matrixJobsByKey = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
// Reusable callers are distinct workflow files — never fold them into a matrix bucket
|
||||
// even if their display name happens to look like "name (variant)".
|
||||
if (job.isReusableCaller) continue;
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
if (!matrixKey) continue;
|
||||
if (!matrixJobsByKey.has(matrixKey)) matrixJobsByKey.set(matrixKey, []);
|
||||
matrixJobsByKey.get(matrixKey)!.push(job);
|
||||
}
|
||||
for (const list of matrixJobsByKey.values()) {
|
||||
list.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
|
||||
}
|
||||
|
||||
const directNeedsByJobId = buildDirectNeedsMap(jobs);
|
||||
const rawLevels = computeJobLevels(jobs);
|
||||
const dependentsByJobId = new Map<string, string[]>();
|
||||
const rawEdges: Array<{from: ActionsJob; to: ActionsJob}> = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
for (const need of directNeedsByJobId.get(job.jobId) || []) {
|
||||
for (const upstream of jobsByJobId.get(need) || []) {
|
||||
rawEdges.push({from: upstream, to: job});
|
||||
if (!dependentsByJobId.has(upstream.jobId)) dependentsByJobId.set(upstream.jobId, []);
|
||||
dependentsByJobId.get(upstream.jobId)!.push(job.jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const list of dependentsByJobId.values()) list.sort();
|
||||
|
||||
// Group sibling jobs that share an identical (parents, children) signature into a single
|
||||
// collapsed "group" node. This is a visual aggregation only - the underlying jobs are
|
||||
// preserved on the node so the panel can list them.
|
||||
const groupedJobIds = new Map<number, string>();
|
||||
const groupsById = new Map<string, ActionsJob[]>();
|
||||
const groupCandidateBuckets = new Map<string, ActionsJob[]>();
|
||||
for (const job of jobs) {
|
||||
if (matrixKeyFromJobName(job.name)) continue;
|
||||
// Reusable callers represent distinct workflow files — keep each as its own node so the
|
||||
// graph mirrors GitHub Actions, where every caller shows up as its own box even when
|
||||
// siblings share an identical (parents, children) dependency signature.
|
||||
if (job.isReusableCaller) continue;
|
||||
const needsKey = canonicalKey(directNeedsByJobId.get(job.jobId) || []);
|
||||
const childrenKey = (dependentsByJobId.get(job.jobId) || []).join('');
|
||||
if (!needsKey && !childrenKey) continue;
|
||||
const level = rawLevels.get(job.jobId) ?? 0;
|
||||
const key = `group:${level}:${needsKey}:${childrenKey}`;
|
||||
if (!groupCandidateBuckets.has(key)) groupCandidateBuckets.set(key, []);
|
||||
groupCandidateBuckets.get(key)!.push(job);
|
||||
}
|
||||
for (const [groupId, groupJobs] of groupCandidateBuckets) {
|
||||
if (groupJobs.length < 2) continue;
|
||||
groupJobs.sort((a, b) => (jobIndexById.get(a.id) ?? 0) - (jobIndexById.get(b.id) ?? 0));
|
||||
groupsById.set(groupId, groupJobs);
|
||||
for (const job of groupJobs) groupedJobIds.set(job.id, groupId);
|
||||
}
|
||||
|
||||
const visualIdByJobId = new Map<number, string>();
|
||||
for (const job of jobs) {
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
// Symmetric with the matrix-bucket loop above: a reusable caller whose display name
|
||||
// happens to look like "name (variant)" must never be folded into the matrix node, or it
|
||||
// would silently vanish (its visualId would point at a matrix node it isn't part of).
|
||||
if (matrixKey && !job.isReusableCaller && (matrixJobsByKey.get(matrixKey)?.length ?? 0) > 1) {
|
||||
visualIdByJobId.set(job.id, `matrix:${matrixKey}`);
|
||||
continue;
|
||||
}
|
||||
visualIdByJobId.set(job.id, groupedJobIds.get(job.id) || graphIdForJob(job));
|
||||
}
|
||||
|
||||
const emittedNodeIds = new Set<string>();
|
||||
const nodes: GraphNode[] = [];
|
||||
for (const job of jobs) {
|
||||
const visualId = visualIdByJobId.get(job.id);
|
||||
if (!visualId || emittedNodeIds.has(visualId)) continue;
|
||||
emittedNodeIds.add(visualId);
|
||||
|
||||
const matrixKey = matrixKeyFromJobName(job.name);
|
||||
if (matrixKey && visualId.startsWith('matrix:')) {
|
||||
const matrixJobs = matrixJobsByKey.get(matrixKey) || [];
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'matrix',
|
||||
name: matrixKey,
|
||||
status: aggregateStatus(matrixJobs),
|
||||
duration: '',
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: matrixPanelHeight(matrixJobs.length, expandedMatrixKeys.has(matrixKey), options),
|
||||
jobs: matrixJobs,
|
||||
matrixKey,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupJobs = groupsById.get(visualId);
|
||||
if (groupJobs) {
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'group',
|
||||
name: groupJobs.map((g) => g.name).join(', '),
|
||||
status: aggregateStatus(groupJobs),
|
||||
duration: '',
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: groupPanelHeight(groupJobs.length, options),
|
||||
jobs: groupJobs,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: visualId,
|
||||
type: 'job',
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
duration: job.duration,
|
||||
x: 0, y: 0, level: 0,
|
||||
displayHeight: options.nodeHeight,
|
||||
jobs: [job],
|
||||
});
|
||||
}
|
||||
|
||||
const seenEdges = new Set<string>();
|
||||
const edges: Edge[] = [];
|
||||
for (const {from, to} of rawEdges) {
|
||||
const fromId = visualIdByJobId.get(from.id);
|
||||
const toId = visualIdByJobId.get(to.id);
|
||||
if (!fromId || !toId || fromId === toId) continue;
|
||||
const key = `${fromId}->${toId}`;
|
||||
if (seenEdges.has(key)) continue;
|
||||
seenEdges.add(key);
|
||||
edges.push({fromId, toId, key});
|
||||
}
|
||||
|
||||
return {nodes, edges};
|
||||
}
|
||||
|
||||
function buildNodeAdjacency(edges: Edge[]): NodeAdjacency {
|
||||
const incomingByNodeId = new Map<string, string[]>();
|
||||
const outgoingByNodeId = new Map<string, string[]>();
|
||||
for (const edge of edges) {
|
||||
if (!incomingByNodeId.has(edge.toId)) incomingByNodeId.set(edge.toId, []);
|
||||
incomingByNodeId.get(edge.toId)!.push(edge.fromId);
|
||||
if (!outgoingByNodeId.has(edge.fromId)) outgoingByNodeId.set(edge.fromId, []);
|
||||
outgoingByNodeId.get(edge.fromId)!.push(edge.toId);
|
||||
}
|
||||
return {incomingByNodeId, outgoingByNodeId};
|
||||
}
|
||||
|
||||
function assignNodeLevels(nodes: GraphNode[], {incomingByNodeId}: NodeAdjacency): void {
|
||||
const cache = new Map<string, number>();
|
||||
function levelFor(id: string, visiting = new Set<string>()): number {
|
||||
if (cache.has(id)) return cache.get(id)!;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
const incoming = incomingByNodeId.get(id) || [];
|
||||
const level = incoming.length > 0 ?
|
||||
Math.max(...incoming.map((fromId) => levelFor(fromId, visiting))) + 1 :
|
||||
0;
|
||||
visiting.delete(id);
|
||||
cache.set(id, level);
|
||||
return level;
|
||||
}
|
||||
for (const node of nodes) node.level = levelFor(node.id);
|
||||
}
|
||||
|
||||
// Roots stay in input order; later levels are sorted by the mean parent Y so that simple
|
||||
// chains stay on a straight horizontal line.
|
||||
function assignNodeCoordinates(nodesById: Map<string, GraphNode>, nodes: GraphNode[], adjacency: NodeAdjacency, options: WorkflowGraphLayoutOptions): void {
|
||||
const {incomingByNodeId} = adjacency;
|
||||
const inputRank = (node: GraphNode): number => Math.min(...node.jobs.map((j) => j.id));
|
||||
|
||||
const nodesByLevel = new Map<number, GraphNode[]>();
|
||||
for (const node of nodes) {
|
||||
if (!nodesByLevel.has(node.level)) nodesByLevel.set(node.level, []);
|
||||
nodesByLevel.get(node.level)!.push(node);
|
||||
}
|
||||
const orderedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b);
|
||||
|
||||
// Initial X assignment and a default Y so barycenters can use a finite value.
|
||||
for (const level of orderedLevels) {
|
||||
const list = nodesByLevel.get(level)!;
|
||||
list.sort((a, b) => inputRank(a) - inputRank(b));
|
||||
let yCursor = options.margin;
|
||||
for (const node of list) {
|
||||
node.x = options.margin + level * (options.nodeWidth + options.columnGap);
|
||||
node.y = yCursor;
|
||||
yCursor += node.displayHeight + options.laneGap;
|
||||
}
|
||||
}
|
||||
|
||||
function packLevel(level: number, anchorOf: (n: GraphNode) => number): void {
|
||||
const list = nodesByLevel.get(level)!;
|
||||
const sorted = Array.from(list).sort((a, b) => anchorOf(a) - anchorOf(b) || inputRank(a) - inputRank(b));
|
||||
// Pack tight to top after sorting. Using barycenter only for order (not Y) keeps terminal
|
||||
// nodes like build-image close to the top of their column instead of being pulled down to
|
||||
// the mean Y of their parents — matching GitHub Actions' compact layout.
|
||||
let prevBottom = options.margin - options.laneGap;
|
||||
for (const node of sorted) {
|
||||
node.y = prevBottom + options.laneGap;
|
||||
prevBottom = boxBottom(node);
|
||||
}
|
||||
nodesByLevel.set(level, sorted);
|
||||
}
|
||||
|
||||
function meanCenterOf(ids: string[]): number | null {
|
||||
if (ids.length === 0) return null;
|
||||
let sum = 0;
|
||||
for (const id of ids) sum += boxCenterY(nodesById.get(id)!);
|
||||
return sum / ids.length;
|
||||
}
|
||||
|
||||
// Down-only barycenter pass: each child is anchored to the mean Y of its parents. Roots
|
||||
// keep their initial yaml-declaration order (via inputRank), matching how GitHub Actions
|
||||
// arranges root jobs. This produces a "main chain on top" layout where job-100 → job-101 →
|
||||
// job-102 stays on a straight horizontal line.
|
||||
for (const level of orderedLevels) {
|
||||
if (level === 0) continue;
|
||||
packLevel(level, (node) => meanCenterOf(incomingByNodeId.get(node.id) || []) ?? boxCenterY(node));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-edge connector: source stub → cubic-bezier corner down/up to column midpoint →
|
||||
// vertical run → cubic-bezier corner back to horizontal → target stub. The corner radius is
|
||||
// fixed (not clamped to the row delta) so any two edges sharing the same source produce the
|
||||
// same source-side path and overlap into a single visual line until they diverge at the V.
|
||||
const cornerRadius = 12;
|
||||
|
||||
function connectorPath(sx: number, sy: number, ex: number, ey: number, options: WorkflowGraphLayoutOptions): string {
|
||||
if (Math.abs(sy - ey) < 0.5) return `M ${sx} ${sy} H ${ex}`;
|
||||
// Anchor the V segment in the column gap immediately before the target instead of the
|
||||
// horizontal midpoint. The long H stays at the source's Y, matching GitHub Actions' style
|
||||
// — a multi-column edge runs along the source row across intermediate columns, then turns
|
||||
// up/down only when it reaches the target column.
|
||||
const midX = Math.max(ex - options.columnGap / 2, (sx + ex) / 2);
|
||||
const dy = ey > sy ? 1 : -1;
|
||||
// Keep the same H prefix to `midX - cornerRadius` for every edge so that edges sharing a
|
||||
// source overlap visually until they fork. When there isn't 2*cornerRadius of vertical
|
||||
// room for the V segment, emit a single S-curve between (midX - r, sy) and (midX + r, ey)
|
||||
// instead of a backward V kink.
|
||||
if (Math.abs(ey - sy) < cornerRadius * 2) {
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`H ${midX - cornerRadius}`,
|
||||
`C ${midX} ${sy} ${midX} ${ey} ${midX + cornerRadius} ${ey}`,
|
||||
`H ${ex}`,
|
||||
].join(' ');
|
||||
}
|
||||
const half = cornerRadius / 2;
|
||||
return [
|
||||
`M ${sx} ${sy}`,
|
||||
`H ${midX - cornerRadius}`,
|
||||
`C ${midX - half} ${sy} ${midX} ${sy + half * dy} ${midX} ${sy + cornerRadius * dy}`,
|
||||
`V ${ey - cornerRadius * dy}`,
|
||||
`C ${midX} ${ey - half * dy} ${midX + half} ${ey} ${midX + cornerRadius} ${ey}`,
|
||||
`H ${ex}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildRoutedEdges(
|
||||
nodesById: Map<string, GraphNode>,
|
||||
edges: Edge[],
|
||||
options: WorkflowGraphLayoutOptions,
|
||||
): Pick<WorkflowGraphModel, 'routedEdges' | 'sharedSegments'> {
|
||||
const routedEdges: RoutedEdge[] = [];
|
||||
for (const edge of edges) {
|
||||
const fromNode = nodesById.get(edge.fromId);
|
||||
const toNode = nodesById.get(edge.toId);
|
||||
if (!fromNode || !toNode) continue;
|
||||
const startX = fromNode.x + options.nodeWidth;
|
||||
const endX = toNode.x;
|
||||
const startY = boxCenterY(fromNode);
|
||||
const endY = boxCenterY(toNode);
|
||||
routedEdges.push({...edge, fromNode, toNode, path: connectorPath(startX, startY, endX, endY, options)});
|
||||
}
|
||||
|
||||
return {routedEdges, sharedSegments: []};
|
||||
}
|
||||
|
||||
export function createWorkflowGraphModel(
|
||||
jobs: ActionsJob[],
|
||||
expandedMatrixKeys: ReadonlySet<string> = new Set(),
|
||||
partialOptions: Partial<WorkflowGraphLayoutOptions> = {},
|
||||
): WorkflowGraphModel {
|
||||
const options = {...defaultLayoutOptions, ...partialOptions};
|
||||
const {nodes, edges} = buildVisualGraph(jobs, expandedMatrixKeys, options);
|
||||
const nodesById = new Map(nodes.map((n) => [n.id, n]));
|
||||
const adjacency = buildNodeAdjacency(edges);
|
||||
assignNodeLevels(nodes, adjacency);
|
||||
assignNodeCoordinates(nodesById, nodes, adjacency, options);
|
||||
return {nodes, edges, ...buildRoutedEdges(nodesById, edges, options), adjacency};
|
||||
}
|
||||
|
||||
export function getWorkflowGraphLayoutOptions(partialOptions: Partial<WorkflowGraphLayoutOptions> = {}): WorkflowGraphLayoutOptions {
|
||||
return {...defaultLayoutOptions, ...partialOptions};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
export function initGpgSignup() {
|
||||
const btnProceed = document.getElementById('btn-proceed');
|
||||
if (!btnProceed) return;
|
||||
|
||||
const nonce = (() => {
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
})();
|
||||
|
||||
btnProceed.addEventListener('click', () => {
|
||||
const key = (document.getElementById('gpg_key') as HTMLTextAreaElement).value.trim();
|
||||
if (!key || !key.startsWith('-----BEGIN PGP PUBLIC KEY BLOCK-----')) {
|
||||
alert('Paste a valid armored GPG public key.');
|
||||
return;
|
||||
}
|
||||
(document.getElementById('token-field') as HTMLInputElement).value = nonce;
|
||||
(document.getElementById('hidden-nonce') as HTMLInputElement).value = nonce;
|
||||
(document.getElementById('hidden-gpg-key') as HTMLInputElement).value = key;
|
||||
(document.getElementById('sign-command') as HTMLInputElement).value =
|
||||
`echo "${nonce}" | gpg --clearsign --armor`;
|
||||
document.getElementById('step-key').style.display = 'none';
|
||||
document.getElementById('step-sign').style.display = 'block';
|
||||
});
|
||||
|
||||
document.getElementById('signup-form').addEventListener('submit', (e) => {
|
||||
const sig = (document.getElementById('gpg_signature') as HTMLTextAreaElement).value.trim();
|
||||
if (!sig ||
|
||||
(!sig.startsWith('-----BEGIN PGP SIGNED MESSAGE-----') &&
|
||||
!sig.startsWith('-----BEGIN PGP SIGNATURE-----'))) {
|
||||
e.preventDefault();
|
||||
alert('Paste the GPG signed output.');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-copy-cmd').addEventListener('click', () => {
|
||||
const cmd = (document.getElementById('sign-command') as HTMLInputElement).value;
|
||||
navigator.clipboard.writeText(cmd);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user