Merge branch 'main'

This commit is contained in:
d
2026-06-08 20:25:36 +03:00
parent 5536ea02f3
commit 409e546ab6
63 changed files with 2029 additions and 1655 deletions
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+5 -5
View File
@@ -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
+6 -6
View File
@@ -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'
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+18 -203
View File
@@ -1,208 +1,23 @@
# Gitea
# M8SH - decentralized swiss knife
[![](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml/badge.svg?branch=main)](https://github.com/go-gitea/gitea/actions/workflows/release-nightly.yml?query=branch%3Amain "Release Nightly")
[![](https://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Gitea "Join the Discord chat at https://discord.gg/Gitea")
[![](https://goreportcard.com/badge/gitea.dev)](https://goreportcard.com/report/gitea.dev "Go Report Card")
[![](https://pkg.go.dev/badge/gitea.dev?status.svg)](https://pkg.go.dev/gitea.dev "GoDoc")
[![](https://img.shields.io/github/release/go-gitea/gitea.svg)](https://github.com/go-gitea/gitea/releases/latest "GitHub release")
[![](https://www.codetriage.com/go-gitea/gitea/badges/users.svg)](https://www.codetriage.com/go-gitea/gitea "Help Contribute to Open Source")
[![](https://opencollective.com/gitea/tiers/backers/badge.svg?label=backers&color=brightgreen)](https://opencollective.com/gitea "Become a backer/sponsor of gitea")
[![](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT "License: MIT")
[![](https://badges.crowdin.net/gitea/localized.svg)](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
[![Crowdin](https://badges.crowdin.net/gitea/localized.svg)](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://img.shields.io/discord/322538954119184384.svg?logo=discord&logoColor=white&label=Discord&color=5865F2)](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
![Login](https://dl.gitea.com/screenshots/login.png)
![Register](https://dl.gitea.com/screenshots/register.png)
### User Dashboard
![Home](https://dl.gitea.com/screenshots/home.png)
![Issues](https://dl.gitea.com/screenshots/issues.png)
![Pull Requests](https://dl.gitea.com/screenshots/pull_requests.png)
![Milestones](https://dl.gitea.com/screenshots/milestones.png)
### User Profile
![Profile](https://dl.gitea.com/screenshots/user_profile.png)
### Explore
![Repos](https://dl.gitea.com/screenshots/explore_repos.png)
![Users](https://dl.gitea.com/screenshots/explore_users.png)
![Orgs](https://dl.gitea.com/screenshots/explore_orgs.png)
### Repository
![Home](https://dl.gitea.com/screenshots/repo_home.png)
![Commits](https://dl.gitea.com/screenshots/repo_commits.png)
![Branches](https://dl.gitea.com/screenshots/repo_branches.png)
![Labels](https://dl.gitea.com/screenshots/repo_labels.png)
![Milestones](https://dl.gitea.com/screenshots/repo_milestones.png)
![Releases](https://dl.gitea.com/screenshots/repo_releases.png)
![Tags](https://dl.gitea.com/screenshots/repo_tags.png)
#### Repository Issue
![List](https://dl.gitea.com/screenshots/repo_issues.png)
![Issue](https://dl.gitea.com/screenshots/repo_issue.png)
#### Repository Pull Requests
![List](https://dl.gitea.com/screenshots/repo_pull_requests.png)
![Pull Request](https://dl.gitea.com/screenshots/repo_pull_request.png)
![File](https://dl.gitea.com/screenshots/repo_pull_request_file.png)
![Commits](https://dl.gitea.com/screenshots/repo_pull_request_commits.png)
#### Repository Actions
![List](https://dl.gitea.com/screenshots/repo_actions.png)
![Details](https://dl.gitea.com/screenshots/repo_actions_run.png)
#### Repository Activity
![Activity](https://dl.gitea.com/screenshots/repo_activity.png)
![Contributors](https://dl.gitea.com/screenshots/repo_contributors.png)
![Code Frequency](https://dl.gitea.com/screenshots/repo_code_frequency.png)
![Recent Commits](https://dl.gitea.com/screenshots/repo_recent_commits.png)
### Organization
![Home](https://dl.gitea.com/screenshots/org_home.png)
</details>
<!--
- integrated cloud storage
- integrated calls
- integrated video-conferences
- integrated stickers
- integrated NFT assets, crypto-wallets
-->
+14 -7
View File
@@ -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
}
+66
View File
@@ -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")
}
+8
View File
@@ -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, "@")
+19
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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"
}
}
+378 -528
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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

+4 -4
View File
@@ -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
}
}
+2 -2
View File
@@ -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
}
+7 -17
View File
@@ -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
}
+2 -13
View File
@@ -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)
}
}
+3 -3
View File
@@ -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):
+1 -1
View File
@@ -258,7 +258,7 @@ func GetAllCommits(ctx *context.APIContext) {
ctx.APIErrorInternal(err)
return
} else if commitsCountTotal == 0 {
ctx.APIErrorNotFound("FileCommitsCount", nil)
ctx.APIErrorNotFound()
return
}
+8 -17
View File
@@ -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
}
+2 -12
View File
@@ -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
}
+3 -15
View File
@@ -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
}
+4 -20
View File
@@ -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
+2 -10
View File
@@ -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
}
+2 -10
View File
@@ -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
}
+7 -31
View File
@@ -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 {
+1 -5
View File
@@ -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
}
+1 -5
View File
@@ -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
}
+10 -34
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+3 -15
View File
@@ -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
+3 -3
View File
@@ -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
}
+1 -5
View File
@@ -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
}
+2 -3
View File
@@ -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
}
+1 -1
View File
@@ -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)
+2 -3
View File
@@ -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
}
+1 -1
View File
@@ -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))
+70 -1
View File
@@ -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{
+31 -14
View File
@@ -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
}
}
}
}
+23 -3
View File
@@ -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
View File
@@ -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,
})
}
+14
View File
@@ -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 {
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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)
+1 -3
View File
@@ -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) {
+4 -32
View File
@@ -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);
-6
View File
@@ -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,
+25 -23
View File
@@ -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);
});
}