Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b969123b7f | ||
|
|
c6627af968 | ||
|
|
de4b8277e9 | ||
|
|
acbc75e2bd | ||
|
|
c3325be816 | ||
|
|
d4250bafd9 | ||
|
|
6815d1646c | ||
|
|
8ba4d4ebec | ||
|
|
b2794f96b9 | ||
|
|
b6aa2b61c6 | ||
|
|
74ad781db9 | ||
|
|
ed493f0684 | ||
|
|
af7ba2edef | ||
|
|
8b4252e7f6 | ||
|
|
500a09e044 | ||
|
|
bfebc4b0e1 | ||
|
|
d9534e7bfa | ||
|
|
532828cc82 | ||
|
|
8cf988f0a6 | ||
|
|
04a26f40a0 | ||
|
|
fc4eac390a | ||
|
|
64d559a8e6 | ||
|
|
03372836ab | ||
|
|
1af1e06ac4 | ||
|
|
e07a5de53f | ||
|
|
d4d81af583 | ||
|
|
6cd2c647ec | ||
|
|
37c8604105 | ||
|
|
a9814e789c | ||
|
|
ab10e37acf | ||
|
|
f7bc6b89c1 | ||
|
|
fabc5e6fcb | ||
|
|
a016d678db | ||
|
|
b6e409badd | ||
|
|
2734504cfc | ||
|
|
eee7967b81 | ||
|
|
1df8f91691 | ||
|
|
4654e7eccd | ||
|
|
6ae42ca9c4 | ||
|
|
5e5f5f3116 | ||
|
|
4ce63a1d57 | ||
|
|
07b18467c0 | ||
|
|
e68ee61879 | ||
|
|
0c67849e68 | ||
|
|
762c674bc5 | ||
|
|
8ff71a5e52 | ||
|
|
8343c47bd1 | ||
|
|
c6b2394585 | ||
|
|
4f41ad7b91 | ||
|
|
4812e35486 | ||
|
|
98c61942aa | ||
|
|
cc1df1976b | ||
|
|
1c718da16c | ||
|
|
ce8cf22af9 | ||
|
|
5b9251150c | ||
|
|
1d43b736b5 | ||
|
|
f46c9a9769 | ||
|
|
c9920b7bd0 | ||
|
|
0319358e5e | ||
|
|
9540292596 | ||
|
|
d392fb1438 | ||
|
|
0f5102427e | ||
|
|
cbe1b703dc | ||
|
|
d5e6f273f0 | ||
|
|
15ee850ede | ||
|
|
16c3216dc6 | ||
|
|
b565f3e00a | ||
|
|
122ebcf0a8 | ||
|
|
1b0992eb2e | ||
|
|
c2f130d352 | ||
|
|
2e1be0b114 | ||
|
|
ef927f9fa3 | ||
|
|
59d4825a95 | ||
|
|
10da460c1b | ||
|
|
2003cf4e87 | ||
|
|
736ab982c8 | ||
|
|
08a18d36a6 | ||
|
|
8a6697123f | ||
|
|
2cd4506120 | ||
|
|
649cb6ff3e | ||
|
|
a4781dde89 | ||
|
|
7684221ed4 | ||
|
|
2c2611eab9 | ||
|
|
685b62c60f | ||
|
|
e5891263f8 | ||
|
|
180af33f86 | ||
|
|
ceec230fc0 | ||
|
|
804b9bf120 | ||
|
|
5368542f8e | ||
|
|
645b10087d | ||
|
|
a12f980793 | ||
|
|
21bcca798b |
@@ -13,7 +13,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
@@ -9,7 +9,7 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- if: ${{ inputs.cache == 'true' }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
gobuild:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: ./.github/actions/go-setup
|
||||
with:
|
||||
lint-cache: "true"
|
||||
|
||||
@@ -12,8 +12,8 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
@@ -20,8 +20,8 @@ jobs:
|
||||
if: github.repository == 'go-gitea/gitea' # prevent running on forks
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: renovatebot/github-action@8217b3fc286df088d7c27f3255fe8414463bc0fd # v46.1.15
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: renovatebot/github-action@6d859fc95779be83a0335ca704879b47e5d79641 # v46.1.16
|
||||
with:
|
||||
renovate-version: ${{ env.RENOVATE_VERSION }}
|
||||
configurationFile: renovate.json5
|
||||
|
||||
@@ -12,8 +12,8 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
e2e: ${{ steps.changes.outputs.e2e }}
|
||||
shell: ${{ steps.changes.outputs.shell }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: go-gitea/giteabot@f8a6f4c14d46920b4b5448852be3de72d00066f0 # v1.0.3
|
||||
- uses: go-gitea/giteabot@912675d47455ac93be82d8bda4667a02b20a6fe4 # v1.0.4
|
||||
with:
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||
gitea_fork: giteabot/gitea
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
steps:
|
||||
# pull_request_review runs without repository secrets on fork PRs, so fall
|
||||
# back to the workflow token for the non-backport checks handled here.
|
||||
- uses: go-gitea/giteabot@f8a6f4c14d46920b4b5448852be3de72d00066f0 # v1.0.3
|
||||
- uses: go-gitea/giteabot@912675d47455ac93be82d8bda4667a02b20a6fe4 # v1.0.4
|
||||
with:
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
|
||||
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: ./.github/actions/go-setup
|
||||
with:
|
||||
cache: "false"
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend generate-go
|
||||
# no frontend build here as backend should be able to build, even without any frontend files
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
ports:
|
||||
- "9000:9000"
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: ./.github/actions/go-setup
|
||||
- uses: ./.github/actions/pgsql-shard
|
||||
with:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
ports:
|
||||
- "9000:9000"
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: ./.github/actions/go-setup
|
||||
- run: make deps-backend
|
||||
- run: make backend
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
ports:
|
||||
- "7700:7700"
|
||||
redis:
|
||||
image: redis:latest@sha256:a505f8b9d8ac3ff7b0848055b4abf1901d6d77606774aa1e38bd37f1197ed2b5
|
||||
image: redis:latest@sha256:c904002d182255b6db3cbe3a1e8ce6c187d15390c39500b59fc07181aabff7bf
|
||||
options: >- # wait until redis has started
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
ports:
|
||||
- 10000:10000
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: ./.github/actions/go-setup
|
||||
- name: Add hosts to /etc/hosts
|
||||
run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql devstoreaccount1.azurite.local" | sudo tee -a /etc/hosts'
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
needs: [files-changed]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: ./.github/actions/docker-dryrun
|
||||
with:
|
||||
platform: linux/riscv64
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
needs: files-changed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: ./.github/actions/go-setup
|
||||
- uses: ./.github/actions/node-setup
|
||||
- run: make deps-frontend
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Base-branch checkout only: pull_request_target runs with elevated token; never run PR-head code here.
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -17,11 +17,15 @@ jobs:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install snapcraft --classic
|
||||
|
||||
- name: Authenticate snapcraft
|
||||
shell: bash
|
||||
run: snapcraft login --with <(printf '%s' "$SNAPCRAFT_STORE_CREDENTIALS")
|
||||
|
||||
- name: Remote build
|
||||
run: |
|
||||
snapcraft remote-build \
|
||||
|
||||
@@ -13,16 +13,17 @@ jobs:
|
||||
runs-on: namespace-profile-gitea-release-binary
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
# 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
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
@@ -33,6 +34,8 @@ jobs:
|
||||
- run: make release
|
||||
env:
|
||||
TAGS: bindata
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
- name: import gpg key
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
|
||||
@@ -45,6 +48,7 @@ jobs:
|
||||
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
run: |
|
||||
for f in dist/release/*; do
|
||||
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
|
||||
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
|
||||
done
|
||||
# clean branch name to get the folder name in S3
|
||||
@@ -75,7 +79,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
# 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
|
||||
|
||||
@@ -14,16 +14,17 @@ jobs:
|
||||
runs-on: namespace-profile-gitea-release-binary
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
# 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
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
@@ -34,6 +35,8 @@ jobs:
|
||||
- run: make release
|
||||
env:
|
||||
TAGS: bindata
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
- name: import gpg key
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
|
||||
@@ -46,6 +49,7 @@ jobs:
|
||||
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
run: |
|
||||
for f in dist/release/*; do
|
||||
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
|
||||
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
|
||||
done
|
||||
# clean branch name to get the folder name in S3
|
||||
@@ -86,7 +90,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
# 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
|
||||
|
||||
@@ -17,16 +17,17 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
# 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
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
@@ -37,6 +38,8 @@ jobs:
|
||||
- run: make release
|
||||
env:
|
||||
TAGS: bindata
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
- name: import gpg key
|
||||
id: import_gpg
|
||||
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
|
||||
@@ -49,6 +52,7 @@ jobs:
|
||||
GPG_PASSPHRASE: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||
run: |
|
||||
for f in dist/release/*; do
|
||||
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
|
||||
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
|
||||
done
|
||||
# clean branch name to get the folder name in S3
|
||||
@@ -89,7 +93,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write # to publish to ghcr.io
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
# 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
|
||||
|
||||
@@ -107,6 +107,7 @@ linters:
|
||||
- -QF1008
|
||||
testifylint:
|
||||
disable:
|
||||
- empty
|
||||
- go-require
|
||||
- require-error
|
||||
usetesting:
|
||||
|
||||
+353
@@ -4,6 +4,359 @@ This changelog goes through the changes that have been made in each release
|
||||
without substantial changes to our git log; to see the highlights of what has
|
||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
|
||||
## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
|
||||
|
||||
* BREAKING
|
||||
* Feat(actions)!: improve support for reusable workflows (#37478)
|
||||
* Use Content-Security-Policy: script nonce (#37232)
|
||||
|
||||
* SECURITY
|
||||
* Fix: various security fixes (#38406) (#38426)
|
||||
* Fix(security): harden access checks and migration validation (#38324) (#38400)
|
||||
* Fix: enforce public-only token scope and harden push options / locale parsing (#38323) (#38399)
|
||||
* Fix(pull): re-evaluate review official flag on target branch change (#38319) (#38402)
|
||||
* Fix(api): stop leaking private repo metadata after access revocation (#38321) (#38390)
|
||||
* Fix(lfs): require proof of possession for cross-repo objects (#38322) (#38389)
|
||||
* Fix(mirror): disable HTTP redirects on pull mirror sync (#38320) (#38367)
|
||||
* Fix: golang html template url escaping (#38363) (#38369)
|
||||
* Fix(release): validate web attachment renames against allowed types (#38314) (#38328)
|
||||
* Fix(release): gate draft release attachments on web download endpoints (#38318) (#38325)
|
||||
* Fix(deps): update module github.com/go-git/go-git/v5 to v5.19.1 [security] (#37786)
|
||||
* Fix(oauth): restrict introspection to the token's client (#38042)
|
||||
* Fix(api): don't expose private org membership via public_members (#38145)
|
||||
* Fix(actions): deny fork-PR cross-repo access via collaborative owner (#38214)
|
||||
* Fix(migrations): prevent path traversal in repository restore (#38215)
|
||||
|
||||
* FEATURES
|
||||
* Feat(actions): add workflow status badge modal (#38196)
|
||||
* Feat(actions): support owner-level and global scoped workflows (#38154)
|
||||
* Feat(api): support ref suffixes in compare (#38148)
|
||||
* Feat(actions): implement `jobs.<job_id>.continue-on-error` (#38100)
|
||||
* Feat(actions): show run status on browser tab favicon (#38071)
|
||||
* Feat(api): add token introspection and self-deletion endpoint (#37995)
|
||||
* Feat(api): add q parameter to list branches API for server-side filtering (#37982)
|
||||
* Feat(repo): split repository creation limit into user and org scopes (#37872)
|
||||
* Feat(actions): bulk delete, disable and enable runners in admin UI (#37869)
|
||||
* Feat(actions): List workflows that were executed once but got removed from the default branch (#37835)
|
||||
* Feat(org): add team visibility so org members can discover teams (#37680)
|
||||
* Feat: add raw diff/patch endpoint for repository comparisons (#37632)
|
||||
* Feat: Add avatar stacks (#37594)
|
||||
* Feat(actions): add job summaries (GITHUB_STEP_SUMMARY) (#37500)
|
||||
* Feat(web): Add Jupyter Notebook (.ipynb) Rendering Support (#37433)
|
||||
* Support for Custom URI Schemes in OAuth2 Redirect URIs (#37356)
|
||||
* Feat(orgs): Add search bar for organization members tab page (#37347)
|
||||
* Feat(api): Add assignees APIs (#37330)
|
||||
* Feat(api): Add GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs (#37196)
|
||||
* Serve OpenAPI 3.0 spec at /openapi.v1.json (#37038)
|
||||
* Add project column picker to issue and pull request sidebar (#37037)
|
||||
* Allow multiple projects per issue and pull requests (#36784)
|
||||
* Feat(ui): add "follow rename" to file commit history list (#34994)
|
||||
* Feat(ssh): auto generate additional ssh keys (#33974)
|
||||
|
||||
* ENHANCEMENTS
|
||||
* Enhance(actions): only create filtered-out workflow commit status for required contexts (#38371) (#38385)
|
||||
* Enhance: allow builtin default git config options to be overridden (#38172)
|
||||
* Enhance: allow MathML core elements (#38034)
|
||||
* Enhance(markup): improve issue title rendering (#37908)
|
||||
* Enhance(actions): set descriptive browser tab title on run view (#37870)
|
||||
* Enhance: Migrate remaining gopkg.in/yaml.v3 usages to go.yaml.in/yaml/v4 (#37866)
|
||||
* Enhance(actions): show workflow name from YAML instead of filename (#37833)
|
||||
* Feat(actions): add before/after to PR synchronize event payload (#37827)
|
||||
* Enhance(actions): add branch filters to run list (#37826)
|
||||
* Enhance(actions): Make Summary UI more beautiful with more infos (#37824)
|
||||
* Feat: add copy button to action step header, improve other copy buttons (#37744)
|
||||
* Fix(icon): use repo-forked icon to display forks count (#37731)
|
||||
* Feat(api): add sort and order query parameters to job list endpoints (#37672)
|
||||
* Feat(api): add last_sync to repository API (#37566)
|
||||
* Enhance: Adjust Workflow Graph styling (#37497)
|
||||
* Improve code editor text selection and clean up lint enablement (#37474)
|
||||
* Add mirror auth updates to repo edit API and settings (#37468)
|
||||
* Replace `olivere/elastic` with REST API client, add OpenSearch support (#37411)
|
||||
* Feat: Add default PR branch update style setting (#37410)
|
||||
* Fix inconsistent disabled styling on logged-out repo header buttons (#37406)
|
||||
* Allow fast-forward-only merge when signed commits are required (#37335)
|
||||
* Enhance styling in actions page (#37323)
|
||||
* Fix: improve actions status icons and texts (#37206)
|
||||
* Make Markdown fenced code block work with more syntaxes (#37154)
|
||||
* Fix: Sort action run jobs by JobID and Name with matrix examples (#37046)
|
||||
* Add API endpoint to reply to pull request review comments (#36683)
|
||||
|
||||
* PERFORMANCE
|
||||
* Perf(actions): debounce runner heartbeat writes and throttle task picks (#38281) (#38368)
|
||||
* Perf(web): sort the action_run query by a repo-scoped index when possible (#38155)
|
||||
* Perf: Various performance regression fixes (#38078)
|
||||
* Perf: extend action `c_u` index to include `created_unix` for faster dashboard feeds (#38076)
|
||||
* Batch-load related data in actions run, job, and task API endpoints (#37032)
|
||||
|
||||
* BUGFIXES
|
||||
* Fix(util): reject invalid characters between time-estimate units (#38416) (#38423)
|
||||
* Fix: represent a deleted assignee team as a Ghost team (#38413) (#38419)
|
||||
* Fix(turnstile): route CAPTCHA verification through the configured proxy (#38412) (#38420)
|
||||
* Fix: refresh pull request merge box when the commit status is pending (#38410) (#38411)
|
||||
* Fix: actions task state concurrent update (#38405) (#38409)
|
||||
* Fix(actions): keep workflow run trailing on one row with long branch names (#38382) (#38403)
|
||||
* Fix(web): use locale-aware date formatting for contribution calendar tooltips (#38398) (#38401)
|
||||
* Fix: co-author detection (#38392) (#38397)
|
||||
* Fix: incorrect co-author detection on commit page (#38386) (#38387)
|
||||
* Fix(ui): restore commits table column widths (#38379) (#38383)
|
||||
* Fix: minio init check (#38355) (#38361)
|
||||
* Fix: org project view assignee list (#38357) (#38360)
|
||||
* Fix(actions): release claimed task if context is cancelled during `FetchTask` (#38343) (#38347)
|
||||
* Fix(actions): make runner list pagination order deterministic (#38313) (#38327)
|
||||
* Fix: Improve since/until when counting commits for X-Total-Count (#38243) (#38304)
|
||||
* Fix(actions): prevent chevron overlap with log text when timestamps are enabled (#38227) (#38307)
|
||||
* Fix(workflows): branch protection status checks fail when workflow uses on: paths filter (#38237) (#38302)
|
||||
* Fix(oauth2): persist linkAccountData during auto-link 2FA flow (#38274) (#38295)
|
||||
* Fix(actions): allow Actions bot to push to protected branches (#38284) (#38293)
|
||||
* Fix(actions): include all aggregable run statuses in status filter (#38280) (#38287)
|
||||
* Fix(archiver): use serializable repo-archive queue payload (#38273) (#38283)
|
||||
* Fix: update npm dependencies, fix misc issues (#38257)
|
||||
* Fix(api): respect since/until when counting commits for X-Total-Count (#38204)
|
||||
* Fix: codemirror regressions (#38248)
|
||||
* Fix(api): support HEAD requests on all API GET endpoints (#38245)
|
||||
* Fix(actions): Cleanup workflow status badge code (#38241)
|
||||
* Fix(web): Correctly align the "disabled" label on larger workflow names (#38240)
|
||||
* Fix(actions): don't swallow HTML entities into linkified URLs (#38239)
|
||||
* Fix(packages): accept npm "repository" and "bin" in string form (#38236)
|
||||
* Fix(actions): fix 500 error when canceling a canceling task (#38223)
|
||||
* Fix(deps): update module golang.org/x/image to v0.43.0 [security] (#38219)
|
||||
* Fix(mssql): convert legacy DATETIME columns to DATETIME2 (#38216)
|
||||
* Fix(api): deny private org member enumeration via /members (#38213)
|
||||
* Fix(actions): ensure all waiting jobs get runners in large workflows (#38200)
|
||||
* Fix(deps): update go dependencies (#38194)
|
||||
* Fix(deps): update npm dependencies (#38193)
|
||||
* Fix(cli): default must-change-password to false for bot users (#38175)
|
||||
* Fix(actions): show run index in run view and fix summary graph height (#38165)
|
||||
* Fix: csp (#38162)
|
||||
* Fix(deps): update npm dependencies (#38123)
|
||||
* Fix(mssql): expand legacy issue and comment long-text columns (#38120)
|
||||
* Fix(packages): validate debian distribution and component names (#38116)
|
||||
* Fix(packages): validate module version in goproxy ParsePackage (#38104)
|
||||
* Fix(deps): update dependency esbuild to v0.28.1 [security] (#38097)
|
||||
* Fix: git push hook post receive (#38089)
|
||||
* Fix(ui): prevent commit status popup overflowing its row (#38081)
|
||||
* Fix: validate gem name in rubygems parseMetadataFile (#38061)
|
||||
* Fix: commit display name (#38057)
|
||||
* Fix: csp regressions (#38047)
|
||||
* Fix: api error message (#38031)
|
||||
* Fix(deps): update npm dependencies (#38029)
|
||||
* Fix: pgsql lint (#38022)
|
||||
* Fix(indexer): fix assignee filters in issue search (#38021)
|
||||
* Fix: various dropdown problems (#38020)
|
||||
* Fix: refactor git error handling and make archive streaming handle non-existing commit id (#38007)
|
||||
* Fix: raise git required version to 2.13 (#37996)
|
||||
* Fix: remove "no-transfrom" from the cache-control header (#37985)
|
||||
* Fix(deps): update module github.com/google/go-github/v87 to v88 (#37971)
|
||||
* Fix: use committer time where ever possible as default (#37969)
|
||||
* Fix(deps): update npm dependencies, remove nolyfill (#37968)
|
||||
* Fix(deps): update go dependencies (#37967)
|
||||
* Fix(pull): preserve squash message trailers and additional commit messages (#37954)
|
||||
* Fix(deps): update module golang.org/x/image to v0.41.0 [security] (#37904)
|
||||
* Fix: support ##[command] log prefix in action run UI (#37882)
|
||||
* Fix(deps): update module github.com/google/go-github/v86 to v87 (#37845)
|
||||
* Fix(deps): update npm dependencies (#37844)
|
||||
* Fix(deps): update go dependencies (#37841)
|
||||
* Fix(frontend): resolve Vite assets by manifest source path (#37836)
|
||||
* Fix(locales): Replace hardcoded strings (#37788)
|
||||
* Fix(packages): render markdown links relative to linked repo (#37676)
|
||||
* Fix: persist mirror repository metadata (#37519)
|
||||
* Fix cmd tests by mocking builtin paths (#37369)
|
||||
* Add `form-fetch-action` to some forms, fix "fetch action" resp bug (#37305)
|
||||
* Feat: execute post run cleanup when workflow is cancelled (#37275)
|
||||
* Fix `relative-time` error and improve global error handler (#37241)
|
||||
* Refactor flash message and remove SanitizeHTML template func (#37179)
|
||||
|
||||
* TESTING
|
||||
* Test(e2e): fix race in pdf file render test (#38380) (#38381)
|
||||
* Test: compare key file contents instead of `FileInfo` in `TestInitKeys` (#38330) (#38331)
|
||||
* Test: speed up two tests (#37905)
|
||||
* Test: Fix random failure test (#37887)
|
||||
* Test: fix flaky `issue-comment` close test (#37880)
|
||||
* Test: enable WAL for sqlite integration tests (#37861)
|
||||
* Test: fix flaky `TestResourceIndex` and reduce its runtime (#37847)
|
||||
* Test: run `TestAPIRepoMigrate` offline via a local clone source (#37817)
|
||||
* Ci: shard tests and reduce redundant work (#37618)
|
||||
* Test(e2e): run playwright via container (#37300)
|
||||
* Remove external service dependencies in migration tests (#36866)
|
||||
|
||||
* BUILD
|
||||
* Fix(actions): authenticate snapcraft before nightly remote build (#38252)
|
||||
* Ci: cap Elasticsearch heap in db-tests (#37816)
|
||||
* Build(snap): publish nightly version to snapcraft via actions (#37814)
|
||||
* Ci: split pgsql shards into plain jobs, dedupe setup actions (#37802)
|
||||
* Ci: narrow files-changed frontend filter (#37749)
|
||||
* Ci: add `zizmor` to `lint-actions` (#37720)
|
||||
* Chore: clean up "contrib" dir (#37690)
|
||||
* Fix: snap build (main branch) (#37685)
|
||||
* Ci: Also lint json5 files (#37659)
|
||||
* Feat(editor): broaden language detection in web code editor (#37619)
|
||||
* Build: update pnpm to v11 (#37591)
|
||||
* Refactor(deps): migrate from `nektos/act` fork to `gitea/runner` (#37557)
|
||||
* Refactor: lint bare `fill`/`stroke` colors, add vars for git graph color series (#37543)
|
||||
* Update go js py dependencies (#37525)
|
||||
* Ci: lint PR titles with commitlint (#37498)
|
||||
* Chore: upgrade Go version in devcontainer image to 1.26 (#37374)
|
||||
* Update GitHub Actions to latest major versions (#37313)
|
||||
* Update go js dependencies (#37312)
|
||||
* Fail vite build on rolldown warnings via NODE_ENV=test (#37270)
|
||||
* Remove htmx (#37224)
|
||||
* Replace custom Go formatter with `golangci-lint fmt` (#37194)
|
||||
* Refactor htmx and fetch-action related code (#37186)
|
||||
* Integrate renovate bot for all dependency updates (#37050)
|
||||
* Build(sign): move to sigstore (#38250)
|
||||
|
||||
* DOCS
|
||||
* Docs: update changelog for 1.26.3 & 1.26.4 (#38178)
|
||||
* Docs: fix duplicated word in foreachref doc comment (#38161)
|
||||
* Docs: Clarify criteria for becoming a merger (#38113)
|
||||
* Docs: Publish TOC Election Result 2026 (#38111)
|
||||
* Docs: mark openapi3 as autogenerated in attributes (#37963)
|
||||
* Docs: add development setup guide (#37960)
|
||||
|
||||
* MISC
|
||||
* Revert(sign): restore gpg (#38251)
|
||||
* Refactor: replace legacy `delete-button` with `link-action` (#38143)
|
||||
* Refactor(actions): read runner capabilities from proto field (#38068)
|
||||
* Refactor(api): clarify APIError message usage and fix legacy lint error (#38012)
|
||||
* Refactor: Use db.Get[] instead of db.GetEngine(ctx).Get(bean) to avoid zero value fetching wrong database record (#37977)
|
||||
* Fix(deps): update go dependencies (#37851)
|
||||
* Ci: Fix sync PR labels from the conventional-commit title (#37784) (#37825)
|
||||
* Ci: tweak `files-changed`, add `free-disk-space` (#37819)
|
||||
* Fix(deps): update module golang.org/x/crypto to v0.52.0 [security] (#37806)
|
||||
* Test(e2e): add comment, release, star, PR and fork tests (#37800)
|
||||
* Chore: simplify issue and pull request templates (#37799)
|
||||
* Chore: Update giteabot to fix failure when backport (#37789)
|
||||
* Fix(api): handle partial failures in push mirror synchronization gracefully (#37782)
|
||||
* Fix(deps): update module gitlab.com/gitlab-org/api/client-go/v2 to v2.26.0 (#37771)
|
||||
* Ci: split giteabot workflow (#37770)
|
||||
* Fix(deps): update npm dependencies (#37768)
|
||||
* Refactor(waitgroup): replace Add/Done goroutines with WaitGroup.Go (#37764)
|
||||
* Fix(deps): update module google.golang.org/grpc to v1.81.1 (#37762)
|
||||
* Ci: fix cache-related issues (#37761)
|
||||
* Chore: fix tests (#37760)
|
||||
* Fix(deps): update module github.com/google/go-github/v85 to v86 (#37754)
|
||||
* Fix(deps): update npm dependencies (#37753)
|
||||
* Fix(deps): update go dependencies (#37752)
|
||||
* Chore(deps): update action dependencies (#37751)
|
||||
* Fix(markup): wrap indented code blocks for the code-copy button (#37748)
|
||||
* Chore(db): introduce db.Session and db.EngineMigration interfaces (#37746)
|
||||
* Feat(web): also display PR counts in repo list (#37739)
|
||||
* Refactor(glob): use strings.Builder for regexp compilation (#37730)
|
||||
* Chore(doctor): remove four obsolete doctor check implementations (#37728)
|
||||
* Refactor(org): simplify owner-team org repo creation logic (#37727)
|
||||
* Refactor: move `workflowpattern` into `modules/actions` (#37717)
|
||||
* Chore: clean up tests (#37715)
|
||||
* Style: misc UI fixes (#37691)
|
||||
* Ci: add shellcheck linter (#37682)
|
||||
* Fix: catch and fix more lint problems (#37674)
|
||||
* Fix(deps): update dependency mermaid to v11.15.0 [security], add e2e test (#37662)
|
||||
* Fix(deps): update npm dependencies (#37647)
|
||||
* Ci(renovate): update Go import paths on major bumps (#37641)
|
||||
* Fix(deps): update go dependencies (major) (#37639)
|
||||
* Chore(deps): update action dependencies (major) (#37638)
|
||||
* Fix(deps): update module code.gitea.io/sdk/gitea to v0.25.0 (#37637)
|
||||
* Fix(deps): update npm dependencies (#37636)
|
||||
* Refactor(log): replace log.Critical with log.Error (#37624)
|
||||
* Build(deps): bump fast-uri from 3.1.0 to 3.1.2 (#37616)
|
||||
* Feat(oauth): Support AWS Cognito OAuth2 provider (#37607)
|
||||
* Chore(deps): update action dependencies (#37603)
|
||||
* Ci: allow `chore` type in PR title lint (#37575)
|
||||
* Refactor: only reset a database table when the table's data was changed (#37573)
|
||||
* Ci: increase renovate frequency and fix RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS (#37565)
|
||||
* Refactor: use modernc sqlite driver as default (#37562)
|
||||
* Docs: fix 4 typos in CHANGELOG.md (#37549)
|
||||
* Fix(deps): update go dependencies (#37541)
|
||||
* Chore(deps): update action dependencies (#37540)
|
||||
* Refactor pull request view (6) (#37522)
|
||||
* Fix: redirect early CLI console logger to stderr (#37507)
|
||||
* Refactor "flex-list" to "flex-divided-list" (#37505)
|
||||
* Refactor compare diff/pull page (1) (#37481)
|
||||
* Refactor pull request view (4) (#37451)
|
||||
* Update 1.26.1 changelog in main (#37442)
|
||||
* Refactor: use named `Permission` field in `Repository` struct instead of anonymous embedding (#37441)
|
||||
* Refactor: serve site manifest via `/assets/site-manifest.json` endpoint (#37405)
|
||||
* Remove IsValidExternalURL/IsAPIURL and use IsValidURL at call sites (#37364)
|
||||
* Update `Block a user` form (#37359)
|
||||
* Move review request functions to a standalone file (#37358)
|
||||
* Feat(security): set X-Content-Type-Options: nosniff by default (#37354)
|
||||
* Enable strict TypeScript, add `errorMessage` helper (#37292)
|
||||
* Refactor frontend `tw-justify-between` layouts to `flex-left-right` (#37291)
|
||||
* Update Nix flake (#37284)
|
||||
* Fix Repository transferring page (#37277)
|
||||
* Remove `SubmitEvent` polyfill (#37276)
|
||||
* Remove dead code identified by `deadcode` tool (#37271)
|
||||
* Upgrade go-git to v5.18.0 (#37268)
|
||||
* Don't add useless labels which will bother changelog generation (#37267)
|
||||
* Move heatmap to first-party code (#37262)
|
||||
* Tests/integration: simplify code (#37249)
|
||||
* Add pagination and search box to org teams list (#37245)
|
||||
* Remove error returns from crypto random helpers and callers (#37240)
|
||||
* Add `ExternalIDClaim` option for OAuth2 OIDC auth source (#37229)
|
||||
* Refactor: simplify ParseCatFileTreeLine and catBatchParseTreeEntries (#37210)
|
||||
* Refactor "htmx" to "fetch action" (#37208)
|
||||
* Update go js py dependencies (#37204)
|
||||
* Add comment for the design of "user activity time" (#37195)
|
||||
* Remove outdated RunUser logic (#37180)
|
||||
* Models/fixtures: add "DO NOT add more test data" comment to all yml fixture files (#37150)
|
||||
* Update javascript dependencies (#37142)
|
||||
* Update go dependencies (#37141)
|
||||
* Frontport changelog of v1.26.0-rc0 (#37138)
|
||||
* Introduce `ActionRunAttempt` to represent each execution of a run (#37119)
|
||||
* Workflow Artifact Info Hover (#37100)
|
||||
* Extend issue context popup beyond markdown content (#36908)
|
||||
* Add bulk repository deletion for organizations (#36763)
|
||||
* Feat: Add bypass allowlist for branch protection (#36514)
|
||||
|
||||
## [1.26.4](https://github.com/go-gitea/gitea/releases/tag/1.26.4) - 2026-06-21
|
||||
|
||||
* SECURITY
|
||||
* fix(auth): do not auto-reactivate disabled users on OAuth2 callback (#38009) (#38183)
|
||||
|
||||
* BUGFIXES
|
||||
* fix: walk git log context error handling (#38182) (#38185)
|
||||
|
||||
## [1.26.3](https://github.com/go-gitea/gitea/releases/tag/1.26.3) - 2026-06-18
|
||||
|
||||
* BREAKING
|
||||
* fix(actions)!: require merged PR to bypass fork PR approval gate (#38010) (#38041)
|
||||
|
||||
* SECURITY
|
||||
* fix(hostmatcher): patch incorrect private list (#38170) (#38173)
|
||||
* fix: Various security fixes (#38103) (#38151)
|
||||
* fix: Various sec fixes (#38108) (#38147)
|
||||
* fix: allow git clone of private repos with anonymous code access (#38074) (#38146)
|
||||
* fix(auth): ignore stale OIDC external login links to organizations (#37875) (#38141)
|
||||
* fix(hostmatcher): block reserved IP ranges from external/private filters (#38039) (#38059)
|
||||
* fix(lfs): require Code-unit access for cross-repo LFS object reuse (#38006) (#38050)
|
||||
* fix(lfs): reject unknown SSH LFS sub-verbs to prevent auth bypass (#38008) (#38015)
|
||||
* fix: bound CODEOWNERS regex match time (#38011) (#38025)
|
||||
* fix: bound debian ParseControlFile to a single control stanza (#38044) (#38055)
|
||||
* fix(deps): update module golang.org/x/net to v0.55.0 [security] (#37813) (#37829)
|
||||
|
||||
* API
|
||||
* feat(api): add Link header in ListForks (#38052) (#38063)
|
||||
|
||||
* BUGFIXES
|
||||
* fix: Fix the panic when ssh remote lfs endpoint parsing failure (#38026) (#38158)
|
||||
* fix(api): nil pointer panic when filtering tracked times by a non-existent user (#38112) (#38115)
|
||||
* fix: keep literal "false" value displayed in workflow_dispatch choice dropdowns (#38080) (#38096)
|
||||
* fix: parse HEAD ref (#38119)
|
||||
* fix: git cmd (#38084) (#38087)
|
||||
* fix(releases): generate notes for initial tag (#37697) (#37986)
|
||||
* fix(actions): return 404 when job log blob is missing (#38003) (#38004)
|
||||
* fix(actions): exclude `workflow_call` from workflow trigger detection (#37894) (#37899)
|
||||
* fix(actions): keep action run title clickable when commit subject is a URL (#37867) (#37898)
|
||||
* fix(actions): reject workflow_dispatch for workflows without that trigger (#37660) (#37895)
|
||||
* fix(actions): ack re-sent `UpdateLog` finalize idempotently (#37885) (#37892)
|
||||
* fix: http content file render (#37850) (#37856)
|
||||
* fix(issues): clear stale ReviewTypeRequest when submitting pending review (#37809) (#37815)
|
||||
* fix: Fix issue target branch selection for non-collaborators (#36916) (#38164)
|
||||
|
||||
* BUILD
|
||||
* fix(deps): update `@playwright/test` to 1.60.0 (#38144)
|
||||
* ci: add `tools/ci-tools.ts` for the PR labeler workflow (#37831)
|
||||
* fix(build): swagger css import (#37801) (#37803)
|
||||
|
||||
## [1.26.2](https://github.com/go-gitea/gitea/releases/tag/1.26.2) - 2026-05-20
|
||||
|
||||
* SECURITY
|
||||
|
||||
@@ -18,7 +18,7 @@ GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go
|
||||
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
|
||||
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.34.1 # renovate: datasource=go
|
||||
XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go
|
||||
GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.4.0 # renovate: datasource=go
|
||||
ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # renovate: datasource=go
|
||||
SHELLCHECK_IMAGE ?= docker.io/koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d # renovate: datasource=docker
|
||||
|
||||
@@ -127,6 +127,7 @@ BINDATA_DEST_WILDCARD := modules/migration/bindata.* modules/public/bindata.* mo
|
||||
GENERATED_GO_DEST := modules/charset/invisible_gen.go modules/charset/ambiguous_gen.go
|
||||
|
||||
SVG_DEST_DIR := public/assets/img/svg
|
||||
SVG_DEST_DIRS := $(SVG_DEST_DIR) options/fileicon
|
||||
|
||||
AIR_TMP_DIR := .air
|
||||
|
||||
@@ -633,10 +634,10 @@ svg: node_modules ## build svg files
|
||||
|
||||
.PHONY: svg-check
|
||||
svg-check: svg
|
||||
@git add $(SVG_DEST_DIR)
|
||||
@diff=$$(git diff --color=always --cached $(SVG_DEST_DIR)); \
|
||||
@git add $(SVG_DEST_DIRS)
|
||||
@diff=$$(git diff --color=always --cached $(SVG_DEST_DIRS)); \
|
||||
if [ -n "$$diff" ]; then \
|
||||
echo "Please run 'make svg' and 'git add $(SVG_DEST_DIR)' and commit the result:"; \
|
||||
echo "Please run 'make svg' and 'git add $(SVG_DEST_DIRS)' and commit the result:"; \
|
||||
printf "%s" "$${diff}"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@@ -158,7 +158,8 @@ func runCreateUser(ctx context.Context, c *cli.Command) error {
|
||||
}
|
||||
|
||||
isAdmin := c.Bool("admin")
|
||||
mustChangePassword := true // always default to true
|
||||
// Only local, existing, regular users should be forced to update their password. Bot users for example are non-interactive
|
||||
mustChangePassword := userType == user_model.UserTypeIndividual
|
||||
if c.IsSet("must-change-password") {
|
||||
if userType != user_model.UserTypeIndividual {
|
||||
return errors.New("must-change-password flag can only be set for individual users")
|
||||
|
||||
@@ -63,6 +63,7 @@ func TestAdminUserCreate(t *testing.T) {
|
||||
u := unittest.AssertExistsAndLoadBean(t, &user_model.User{LowerName: "u"})
|
||||
assert.Equal(t, user_model.UserTypeBot, u.Type)
|
||||
assert.Empty(t, u.Passwd)
|
||||
assert.False(t, u.MustChangePassword, "bot users should not be forced to change password")
|
||||
})
|
||||
|
||||
t.Run("AccessToken", func(t *testing.T) {
|
||||
|
||||
+12
-1
@@ -203,7 +203,7 @@ func TestCliCmdError(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 1, r.ExitCode)
|
||||
assert.Empty(t, r.Stdout)
|
||||
assert.Equal(t, "Incorrect Usage: flag provided but not defined: -no-such\n\n", r.Stderr)
|
||||
assert.Equal(t, "Incorrect Usage: flag provided but not defined: -no-such\n", r.Stderr)
|
||||
|
||||
app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }})
|
||||
r, err = runTestApp(app, "./gitea", "test-cmd")
|
||||
@@ -235,3 +235,14 @@ func TestCliCmdBefore(t *testing.T) {
|
||||
assert.Equal(t, "/tmp/any.ini", configValues["before"], "BeforeFunc must be called before preparing config")
|
||||
assert.Equal(t, "/dev/null", configValues["action"])
|
||||
}
|
||||
|
||||
func TestCliCmdCompletion(t *testing.T) {
|
||||
app := newTestApp(cli.Command{
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error { return nil },
|
||||
})
|
||||
res, err := runTestApp(app, "./gitea", "completion", "bash", "--nonexist")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 1, res.ExitCode)
|
||||
assert.Equal(t, "", res.Stdout)
|
||||
assert.Equal(t, "Incorrect Usage: flag provided but not defined: -nonexist\n", res.Stderr)
|
||||
}
|
||||
|
||||
+25
-4
@@ -5,10 +5,10 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -154,16 +154,37 @@ func NewMainApp(appVer AppVersion) *cli.Command {
|
||||
return app
|
||||
}
|
||||
|
||||
// usageErr marks a usage error already reported by cliOnUsageError, so RunMainApp does not print it again.
|
||||
type usageErr struct{ err error }
|
||||
|
||||
func (e usageErr) Error() string { return e.err.Error() }
|
||||
func (e usageErr) Unwrap() error { return e.err }
|
||||
|
||||
// cliOnUsageError reports usage errors itself instead of letting urfave/cli dump the full help to stdout (since urfave/cli v3.10).
|
||||
func cliOnUsageError(_ context.Context, cmd *cli.Command, err error, _ bool) error {
|
||||
_, _ = fmt.Fprintf(cmd.Root().ErrWriter, "Incorrect Usage: %s\n", err.Error())
|
||||
return usageErr{err}
|
||||
}
|
||||
|
||||
func setCLIOnUsageError(cmd *cli.Command) {
|
||||
_ = cmd.Walk(func(c *cli.Command) error {
|
||||
c.OnUsageError = cliOnUsageError
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func RunMainApp(app *cli.Command, args ...string) error {
|
||||
ctx, cancel := installSignals()
|
||||
defer cancel()
|
||||
setCLIOnUsageError(app)
|
||||
// the completion subcommands are built during app.Run, after the Walk above, so cover them via this hook
|
||||
app.ConfigureShellCompletionCommand = setCLIOnUsageError
|
||||
err := app.Run(ctx, args)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "flag provided but not defined:") {
|
||||
// the cli package should already have output the error message, so just exit
|
||||
cli.OsExiter(1)
|
||||
if _, ok := errors.AsType[usageErr](err); ok {
|
||||
cli.OsExiter(1) // cliOnUsageError already reported it
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(app.ErrWriter, "Command error: %v\n", err)
|
||||
|
||||
@@ -536,6 +536,13 @@ INTERNAL_TOKEN =
|
||||
;; Leave it empty to apply the default policy, or set it to "unset" to disable Content-Security-Policy.
|
||||
;CONTENT_SECURITY_POLICY_GENERAL =
|
||||
|
||||
;; Webhook and oauth2 clients can only call allowed hosts for security reasons. Comma separated list, eg: external, 192.168.1.0/24, *.mydomain.com
|
||||
;; Built-in: loopback (for localhost), private (for LAN/intranet), external (for public hosts on internet), * (for all hosts)
|
||||
;; CIDR list: 1.2.3.0/8, 2001:db8::/32
|
||||
;; Wildcard hosts: *.mydomain.com, 192.168.100.*
|
||||
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
|
||||
;ALLOWED_HOST_LIST = external
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
[camo]
|
||||
@@ -762,7 +769,12 @@ LEVEL = Info
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Git config options
|
||||
;; This section only does "set" config, a removed config key from this section won't be removed from git config automatically. The format is `some.configKey = value`.
|
||||
;; The format is `some.configKey = value`.
|
||||
;; These options will be written into the gitconfig file under "[git] HOME_PATH" when Gitea web starts.
|
||||
;; ATTENTION:
|
||||
;; * It only does "set" config, a removed config key from this section won't be removed from git config automatically.
|
||||
;; * Some config options might affect the behavior of git and fail Gitea's git operation,
|
||||
;; make sure you know what you are doing before making changes.
|
||||
;[git.config]
|
||||
;diff.algorithm = histogram
|
||||
;core.logAllRefUpdates = true
|
||||
@@ -1749,13 +1761,6 @@ LEVEL = Info
|
||||
;; Deliver timeout in seconds
|
||||
;DELIVER_TIMEOUT = 5
|
||||
;;
|
||||
;; Webhook can only call allowed hosts for security reasons. Comma separated list, eg: external, 192.168.1.0/24, *.mydomain.com
|
||||
;; Built-in: loopback (for localhost), private (for LAN/intranet), external (for public hosts on internet), * (for all hosts)
|
||||
;; CIDR list: 1.2.3.0/8, 2001:db8::/32
|
||||
;; Wildcard hosts: *.mydomain.com, 192.168.100.*
|
||||
;; Since 1.15.7. Default to * for 1.15.x, external for 1.16 and later
|
||||
;ALLOWED_HOST_LIST = external
|
||||
;;
|
||||
;; Allow insecure certification
|
||||
;SKIP_TLS_VERIFY = false
|
||||
;;
|
||||
@@ -2997,8 +3002,16 @@ LEVEL = Info
|
||||
;; Comma-separated list of workflow directories, the first one to exist
|
||||
;; in a repo is used to find Actions workflow files
|
||||
;WORKFLOW_DIRS = .gitea/workflows,.github/workflows
|
||||
;; Comma-separated list of scoped workflow directories in a source repository, the first one to exist is used.
|
||||
;; Files here are picked up only when the repo is registered as a scoped-workflow source; in any other repo they neither run repo-level nor scope-level.
|
||||
;; Must not overlap with WORKFLOW_DIRS. Leave empty so no directory is scanned; no scoped workflows are found or run.
|
||||
;SCOPED_WORKFLOW_DIRS = .gitea/scoped_workflows
|
||||
;; Maximum number of attempts a single workflow run can have. Default value is 50.
|
||||
;MAX_RERUN_ATTEMPTS = 50
|
||||
;; Maximum number of runners that may run the task-assignment query concurrently, per Gitea instance.
|
||||
;; Caps this instance's DB load when many runners poll at once; excess runners retry on their next poll.
|
||||
;; In a multi-instance deployment the cluster-wide limit is this value times the number of instances. Default value is 16.
|
||||
;MAX_CONCURRENT_TASK_PICKS = 16
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
+185
-50
@@ -1,7 +1,6 @@
|
||||
import arrayFunc from 'eslint-plugin-array-func';
|
||||
import comments from '@eslint-community/eslint-plugin-eslint-comments';
|
||||
import deMorgan from 'eslint-plugin-de-morgan';
|
||||
import github from 'eslint-plugin-github';
|
||||
import globals from 'globals';
|
||||
import importPlugin from 'eslint-plugin-import-x';
|
||||
import playwright from 'eslint-plugin-playwright';
|
||||
@@ -72,7 +71,6 @@ export default defineConfig([
|
||||
regexp,
|
||||
sonarjs,
|
||||
unicorn,
|
||||
github,
|
||||
wc,
|
||||
},
|
||||
settings: {
|
||||
@@ -86,7 +84,6 @@ export default defineConfig([
|
||||
'@eslint-community/eslint-comments/no-duplicate-disable': [2],
|
||||
'@eslint-community/eslint-comments/no-restricted-disable': [0],
|
||||
'@eslint-community/eslint-comments/no-unlimited-disable': [2],
|
||||
'@eslint-community/eslint-comments/no-unused-disable': [2],
|
||||
'@eslint-community/eslint-comments/no-unused-enable': [2],
|
||||
'@eslint-community/eslint-comments/no-use': [0],
|
||||
'@eslint-community/eslint-comments/require-description': [0],
|
||||
@@ -193,7 +190,6 @@ export default defineConfig([
|
||||
'@typescript-eslint/no-duplicate-type-constituents': [2, {ignoreUnions: true}],
|
||||
'@typescript-eslint/no-dynamic-delete': [0],
|
||||
'@typescript-eslint/no-empty-function': [0],
|
||||
'@typescript-eslint/no-empty-interface': [0],
|
||||
'@typescript-eslint/no-empty-object-type': [2],
|
||||
'@typescript-eslint/no-explicit-any': [0],
|
||||
'@typescript-eslint/no-extra-non-null-assertion': [2],
|
||||
@@ -206,7 +202,6 @@ export default defineConfig([
|
||||
'@typescript-eslint/no-invalid-this': [0],
|
||||
'@typescript-eslint/no-invalid-void-type': [0],
|
||||
'@typescript-eslint/no-loop-func': [0],
|
||||
'@typescript-eslint/no-loss-of-precision': [0],
|
||||
'@typescript-eslint/no-magic-numbers': [0],
|
||||
'@typescript-eslint/no-meaningless-void-operator': [0],
|
||||
'@typescript-eslint/no-misused-new': [2],
|
||||
@@ -235,7 +230,7 @@ export default defineConfig([
|
||||
'@typescript-eslint/no-unsafe-assignment': [0],
|
||||
'@typescript-eslint/no-unsafe-call': [0],
|
||||
'@typescript-eslint/no-unsafe-declaration-merging': [2],
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': [2],
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': [0],
|
||||
'@typescript-eslint/no-unsafe-function-type': [2],
|
||||
'@typescript-eslint/no-unsafe-member-access': [0],
|
||||
'@typescript-eslint/no-unsafe-return': [0],
|
||||
@@ -279,7 +274,6 @@ export default defineConfig([
|
||||
'@typescript-eslint/strict-void-return': [0],
|
||||
'@typescript-eslint/switch-exhaustiveness-check': [0],
|
||||
'@typescript-eslint/triple-slash-reference': [2],
|
||||
'@typescript-eslint/typedef': [0],
|
||||
'@typescript-eslint/unbound-method': [0], // too many false-positives
|
||||
'@typescript-eslint/unified-signatures': [2],
|
||||
'accessor-pairs': [2],
|
||||
@@ -312,32 +306,9 @@ export default defineConfig([
|
||||
'func-names': [0],
|
||||
'func-style': [0],
|
||||
'getter-return': [2],
|
||||
'github/a11y-aria-label-is-well-formatted': [0],
|
||||
'github/a11y-no-title-attribute': [0],
|
||||
'github/a11y-no-visually-hidden-interactive-element': [0],
|
||||
'github/a11y-role-supports-aria-props': [0],
|
||||
'github/a11y-svg-has-accessible-name': [0],
|
||||
'github/array-foreach': [0],
|
||||
'github/async-currenttarget': [2],
|
||||
'github/async-preventdefault': [0], // https://github.com/github/eslint-plugin-github/issues/599
|
||||
'github/authenticity-token': [0],
|
||||
'github/get-attribute': [0],
|
||||
'github/js-class-name': [0],
|
||||
'github/no-blur': [0],
|
||||
'github/no-d-none': [0],
|
||||
'github/no-dataset': [2],
|
||||
'github/no-dynamic-script-tag': [2],
|
||||
'github/no-implicit-buggy-globals': [2],
|
||||
'github/no-inner-html': [0],
|
||||
'github/no-innerText': [2],
|
||||
'github/no-then': [2],
|
||||
'github/no-useless-passive': [2],
|
||||
'github/prefer-observers': [0],
|
||||
'github/require-passive-events': [2],
|
||||
'gitea/unescaped-html-literal': [2],
|
||||
'grouped-accessor-pairs': [2],
|
||||
'guard-for-in': [0],
|
||||
'id-blacklist': [0],
|
||||
'id-denylist': [0],
|
||||
'id-length': [0],
|
||||
'id-match': [0],
|
||||
'import-x/consistent-type-specifier-style': [0],
|
||||
@@ -384,7 +355,6 @@ export default defineConfig([
|
||||
'import-x/prefer-default-export': [0],
|
||||
'import-x/unambiguous': [0],
|
||||
'init-declarations': [0],
|
||||
'line-comment-position': [0],
|
||||
'logical-assignment-operators': [0],
|
||||
'max-classes-per-file': [0],
|
||||
'max-depth': [0],
|
||||
@@ -393,14 +363,12 @@ export default defineConfig([
|
||||
'max-nested-callbacks': [0],
|
||||
'max-params': [0],
|
||||
'max-statements': [0],
|
||||
'multiline-comment-style': [0],
|
||||
'new-cap': [0],
|
||||
'no-alert': [0],
|
||||
'no-array-constructor': [0], // handled by @typescript-eslint/no-array-constructor
|
||||
'no-async-promise-executor': [0],
|
||||
'no-await-in-loop': [0],
|
||||
'no-bitwise': [0],
|
||||
'no-buffer-constructor': [0],
|
||||
'no-caller': [2],
|
||||
'no-case-declarations': [2],
|
||||
'no-class-assign': [2],
|
||||
@@ -557,12 +525,11 @@ export default defineConfig([
|
||||
'no-nested-ternary': [0],
|
||||
'no-new-func': [0], // handled by @typescript-eslint/no-implied-eval
|
||||
'no-new-native-nonconstructor': [2],
|
||||
'no-new-object': [2],
|
||||
'no-new-symbol': [0], // handled by no-new-native-nonconstructor
|
||||
'no-new-wrappers': [2],
|
||||
'no-new': [0],
|
||||
'no-nonoctal-decimal-escape': [2],
|
||||
'no-obj-calls': [2],
|
||||
'no-object-constructor': [2],
|
||||
'no-octal-escape': [2],
|
||||
'no-octal': [2],
|
||||
'no-param-reassign': [0],
|
||||
@@ -622,10 +589,8 @@ export default defineConfig([
|
||||
'no-warning-comments': [0],
|
||||
'no-with': [0], // handled by no-restricted-syntax
|
||||
'object-shorthand': [2, 'always'],
|
||||
'one-var-declaration-per-line': [0],
|
||||
'one-var': [0],
|
||||
'operator-assignment': [2, 'always'],
|
||||
'operator-linebreak': [0], // handled by @stylistic/operator-linebreak
|
||||
'prefer-arrow-callback': [2, {allowNamedFunctions: true, allowUnboundThis: true}],
|
||||
'prefer-const': [2, {destructuring: 'all', ignoreReadBeforeAssign: true}],
|
||||
'prefer-destructuring': [0],
|
||||
@@ -761,139 +726,309 @@ export default defineConfig([
|
||||
'strict': [0],
|
||||
'symbol-description': [2],
|
||||
'unicode-bom': [2, 'never'],
|
||||
'unicorn/better-regex': [0],
|
||||
'unicorn/better-dom-traversing': [2],
|
||||
'unicorn/catch-error-name': [0],
|
||||
'unicorn/class-reference-in-static-methods': [2],
|
||||
'unicorn/comment-content': [0],
|
||||
'unicorn/consistent-assert': [0],
|
||||
'unicorn/consistent-boolean-name': [0],
|
||||
'unicorn/consistent-class-member-order': [0],
|
||||
'unicorn/consistent-compound-words': [0], // too opinionated
|
||||
'unicorn/consistent-conditional-object-spread': [2],
|
||||
'unicorn/consistent-date-clone': [2],
|
||||
'unicorn/consistent-destructuring': [2],
|
||||
'unicorn/consistent-empty-array-spread': [2],
|
||||
'unicorn/consistent-template-literal-escape': [2],
|
||||
'unicorn/consistent-existence-index-check': [0],
|
||||
'unicorn/consistent-export-decorator-position': [2],
|
||||
'unicorn/consistent-function-scoping': [0],
|
||||
'unicorn/consistent-function-style': [2],
|
||||
'unicorn/consistent-json-file-read': [2],
|
||||
'unicorn/consistent-optional-chaining': [2],
|
||||
'unicorn/consistent-template-literal-escape': [2],
|
||||
'unicorn/custom-error-definition': [0],
|
||||
'unicorn/default-export-style': [2],
|
||||
'unicorn/dom-node-dataset': [2, {preferAttributes: true}],
|
||||
'unicorn/empty-brace-spaces': [2],
|
||||
'unicorn/error-message': [0],
|
||||
'unicorn/escape-case': [0],
|
||||
'unicorn/expiring-todo-comments': [0],
|
||||
'unicorn/explicit-length-check': [0],
|
||||
'unicorn/explicit-timer-delay': [2],
|
||||
'unicorn/filename-case': [0],
|
||||
'unicorn/import-index': [0],
|
||||
'unicorn/id-match': [2],
|
||||
'unicorn/import-style': [0],
|
||||
'unicorn/isolated-functions': [2, {functions: []}],
|
||||
'unicorn/logical-assignment-operators': [0],
|
||||
'unicorn/max-nested-calls': [0],
|
||||
'unicorn/name-replacements': [0],
|
||||
'unicorn/new-for-builtins': [2],
|
||||
'unicorn/no-abusive-eslint-disable': [0],
|
||||
'unicorn/no-accessor-recursion': [2],
|
||||
'unicorn/no-accidental-bitwise-operator': [2],
|
||||
'unicorn/no-anonymous-default-export': [0],
|
||||
'unicorn/no-array-callback-reference': [0],
|
||||
'unicorn/no-array-for-each': [2],
|
||||
'unicorn/no-array-concat-in-loop': [2],
|
||||
'unicorn/no-array-fill-with-reference-type': [2],
|
||||
'unicorn/no-array-from-fill': [2],
|
||||
'unicorn/no-array-front-mutation': [0],
|
||||
'unicorn/no-array-method-this-argument': [2],
|
||||
'unicorn/no-array-push-push': [2],
|
||||
'unicorn/no-array-reduce': [2],
|
||||
'unicorn/no-array-reverse': [0],
|
||||
'unicorn/no-array-sort': [0],
|
||||
'unicorn/no-array-sort-for-min-max': [2],
|
||||
'unicorn/no-array-splice': [0],
|
||||
'unicorn/no-asterisk-prefix-in-documentation-comments': [0],
|
||||
'unicorn/no-await-expression-member': [0],
|
||||
'unicorn/no-await-in-promise-methods': [2],
|
||||
'unicorn/no-blob-to-file': [2],
|
||||
'unicorn/no-boolean-sort-comparator': [2],
|
||||
'unicorn/no-break-in-nested-loop': [0],
|
||||
'unicorn/no-canvas-to-image': [2],
|
||||
'unicorn/no-chained-comparison': [2],
|
||||
'unicorn/no-collection-bracket-access': [2],
|
||||
'unicorn/no-computed-property-existence-check': [0],
|
||||
'unicorn/no-confusing-array-splice': [2],
|
||||
'unicorn/no-confusing-array-with': [2],
|
||||
'unicorn/no-console-spaces': [0],
|
||||
'unicorn/no-constant-zero-expression': [2],
|
||||
'unicorn/no-declarations-before-early-exit': [0],
|
||||
'unicorn/no-document-cookie': [2],
|
||||
'unicorn/no-double-comparison': [2],
|
||||
'unicorn/no-duplicate-if-branches': [2],
|
||||
'unicorn/no-duplicate-logical-operands': [2],
|
||||
'unicorn/no-duplicate-loops': [0],
|
||||
'unicorn/no-duplicate-set-values': [2],
|
||||
'unicorn/no-empty-file': [2],
|
||||
'unicorn/no-error-property-assignment': [2],
|
||||
'unicorn/no-exports-in-scripts': [2],
|
||||
'unicorn/no-for-each': [2],
|
||||
'unicorn/no-for-loop': [0],
|
||||
'unicorn/no-hex-escape': [0],
|
||||
'unicorn/no-global-object-property-assignment': [0],
|
||||
'unicorn/no-immediate-mutation': [0],
|
||||
'unicorn/no-instanceof-array': [0],
|
||||
'unicorn/no-impossible-length-comparison': [2],
|
||||
'unicorn/no-incorrect-query-selector': [2],
|
||||
'unicorn/no-incorrect-template-string-interpolation': [0],
|
||||
'unicorn/no-instanceof-builtins': [2],
|
||||
'unicorn/no-invalid-argument-count': [0],
|
||||
'unicorn/no-invalid-character-comparison': [2],
|
||||
'unicorn/no-invalid-fetch-options': [2],
|
||||
'unicorn/no-invalid-file-input-accept': [2],
|
||||
'unicorn/no-invalid-remove-event-listener': [2],
|
||||
'unicorn/no-keyword-prefix': [0],
|
||||
'unicorn/no-length-as-slice-end': [2],
|
||||
'unicorn/no-late-current-target-access': [2],
|
||||
'unicorn/no-lonely-if': [2],
|
||||
'unicorn/no-loop-iterable-mutation': [2],
|
||||
'unicorn/no-magic-array-flat-depth': [0],
|
||||
'unicorn/no-manually-wrapped-comments': [0], // too opinionated
|
||||
'unicorn/no-mismatched-map-key': [2],
|
||||
'unicorn/no-misrefactored-assignment': [2],
|
||||
'unicorn/no-named-default': [2],
|
||||
'unicorn/no-negated-array-predicate': [2],
|
||||
'unicorn/no-negated-comparison': [2],
|
||||
'unicorn/no-negated-condition': [0],
|
||||
'unicorn/no-negation-in-equality-check': [2],
|
||||
'unicorn/no-nested-ternary': [0],
|
||||
'unicorn/no-new-array': [0],
|
||||
'unicorn/no-new-buffer': [0],
|
||||
'unicorn/no-non-function-verb-prefix': [0],
|
||||
'unicorn/no-nonstandard-builtin-properties': [2],
|
||||
'unicorn/no-null': [0],
|
||||
'unicorn/no-object-as-default-parameter': [0],
|
||||
'unicorn/no-object-methods-with-collections': [2],
|
||||
'unicorn/no-optional-chaining-on-undeclared-variable': [2],
|
||||
'unicorn/no-process-exit': [0],
|
||||
'unicorn/no-redundant-comparison': [2],
|
||||
'unicorn/no-return-array-push': [2],
|
||||
'unicorn/no-selector-as-dom-name': [2],
|
||||
'unicorn/no-single-promise-in-promise-methods': [2],
|
||||
'unicorn/no-static-only-class': [2],
|
||||
'unicorn/no-subtraction-comparison': [2],
|
||||
'unicorn/no-thenable': [2],
|
||||
'unicorn/no-this-assignment': [2],
|
||||
'unicorn/no-this-outside-of-class': [0], // gitea uses `this` in non-class functions
|
||||
'unicorn/no-top-level-assignment-in-function': [0],
|
||||
'unicorn/no-top-level-side-effects': [0],
|
||||
'unicorn/no-typeof-undefined': [2],
|
||||
'unicorn/no-uncalled-method': [2],
|
||||
'unicorn/no-undeclared-class-members': [2],
|
||||
'unicorn/no-unnecessary-array-flat-depth': [2],
|
||||
'unicorn/no-unnecessary-array-splice-count': [2],
|
||||
'unicorn/no-unnecessary-await': [2],
|
||||
'unicorn/no-unnecessary-boolean-comparison': [2],
|
||||
'unicorn/no-unnecessary-global-this': [0],
|
||||
'unicorn/no-unnecessary-nested-ternary': [2],
|
||||
'unicorn/no-unnecessary-polyfills': [2],
|
||||
'unicorn/no-unnecessary-slice-end': [2],
|
||||
'unicorn/no-unnecessary-splice': [2],
|
||||
'unicorn/no-unreadable-array-destructuring': [0],
|
||||
'unicorn/no-unreadable-for-of-expression': [0],
|
||||
'unicorn/no-unreadable-iife': [0],
|
||||
'unicorn/no-unreadable-new-expression': [0],
|
||||
'unicorn/no-unreadable-object-destructuring': [0],
|
||||
'unicorn/no-unsafe-buffer-conversion': [2],
|
||||
'unicorn/no-unsafe-dom-html': [0],
|
||||
'unicorn/no-unsafe-property-key': [0],
|
||||
'unicorn/no-unsafe-string-replacement': [0],
|
||||
'unicorn/no-unused-array-method-return': [2],
|
||||
'unicorn/no-unused-properties': [2],
|
||||
'unicorn/no-useless-boolean-cast': [2],
|
||||
'unicorn/no-useless-coercion': [2],
|
||||
'unicorn/no-useless-collection-argument': [2],
|
||||
'unicorn/no-useless-compound-assignment': [2],
|
||||
'unicorn/no-useless-concat': [2],
|
||||
'unicorn/no-useless-continue': [2],
|
||||
'unicorn/no-useless-delete-check': [2],
|
||||
'unicorn/no-useless-else': [0],
|
||||
'unicorn/no-useless-error-capture-stack-trace': [2],
|
||||
'unicorn/no-useless-fallback-in-spread': [2],
|
||||
'unicorn/no-useless-iterator-to-array': [2],
|
||||
'unicorn/no-useless-length-check': [2],
|
||||
'unicorn/no-useless-logical-operand': [2],
|
||||
'unicorn/no-useless-override': [2],
|
||||
'unicorn/no-useless-promise-resolve-reject': [2],
|
||||
'unicorn/no-useless-recursion': [0],
|
||||
'unicorn/no-useless-spread': [2],
|
||||
'unicorn/no-useless-switch-case': [2],
|
||||
'unicorn/no-useless-template-literals': [2],
|
||||
'unicorn/no-useless-undefined': [0],
|
||||
'unicorn/no-xor-as-exponentiation': [2],
|
||||
'unicorn/no-zero-fractions': [2],
|
||||
'unicorn/number-literal-case': [0],
|
||||
'unicorn/numeric-separators-style': [0],
|
||||
'unicorn/operator-assignment': [2],
|
||||
'unicorn/prefer-add-event-listener': [2],
|
||||
'unicorn/prefer-add-event-listener-options': [2],
|
||||
'unicorn/prefer-array-find': [0], // handled by @typescript-eslint/prefer-find
|
||||
'unicorn/prefer-array-flat': [2],
|
||||
'unicorn/prefer-array-flat-map': [2],
|
||||
'unicorn/prefer-array-from-async': [2],
|
||||
'unicorn/prefer-array-from-map': [2],
|
||||
'unicorn/prefer-array-index-of': [2],
|
||||
'unicorn/prefer-array-iterable-methods': [2],
|
||||
'unicorn/prefer-array-last-methods': [2],
|
||||
'unicorn/prefer-array-slice': [2],
|
||||
'unicorn/prefer-array-some': [2],
|
||||
'unicorn/prefer-at': [0],
|
||||
'unicorn/prefer-await': [2],
|
||||
'unicorn/prefer-bigint-literals': [2],
|
||||
'unicorn/prefer-blob-reading-methods': [2],
|
||||
'unicorn/prefer-boolean-return': [2],
|
||||
'unicorn/prefer-class-fields': [2],
|
||||
'unicorn/prefer-classlist-toggle': [2],
|
||||
'unicorn/prefer-code-point': [0],
|
||||
'unicorn/prefer-continue': [0],
|
||||
'unicorn/prefer-date-now': [2],
|
||||
'unicorn/prefer-default-parameters': [0],
|
||||
'unicorn/prefer-direct-iteration': [2],
|
||||
'unicorn/prefer-dispose': [2],
|
||||
'unicorn/prefer-dom-node-append': [2],
|
||||
'unicorn/prefer-dom-node-dataset': [0],
|
||||
'unicorn/prefer-dom-node-html-methods': [0],
|
||||
'unicorn/prefer-dom-node-remove': [2],
|
||||
'unicorn/prefer-dom-node-text-content': [2],
|
||||
'unicorn/prefer-early-return': [0],
|
||||
'unicorn/prefer-else-if': [2],
|
||||
'unicorn/prefer-event-target': [2],
|
||||
'unicorn/prefer-export-from': [0],
|
||||
'unicorn/prefer-flat-math-min-max': [2],
|
||||
'unicorn/prefer-get-or-insert-computed': [2],
|
||||
'unicorn/prefer-global-number-constants': [2],
|
||||
'unicorn/prefer-global-this': [0],
|
||||
'unicorn/prefer-has-check': [2],
|
||||
'unicorn/prefer-hoisting-branch-code': [2],
|
||||
'unicorn/prefer-https': [0], // false-positives on namespace and schema URIs
|
||||
'unicorn/prefer-identifier-import-export-specifiers': [2],
|
||||
'unicorn/prefer-import-meta-properties': [2],
|
||||
'unicorn/prefer-includes': [0], // handled by @typescript-eslint/prefer-includes
|
||||
'unicorn/prefer-json-parse-buffer': [0],
|
||||
'unicorn/prefer-includes-over-repeated-comparisons': [0], // too opinionated
|
||||
'unicorn/prefer-iterable-in-constructor': [2],
|
||||
'unicorn/prefer-iterator-concat': [0], // too opinionated
|
||||
'unicorn/prefer-iterator-to-array': [2],
|
||||
'unicorn/prefer-iterator-to-array-at-end': [2],
|
||||
'unicorn/prefer-keyboard-event-key': [2],
|
||||
'unicorn/prefer-location-assign': [2],
|
||||
'unicorn/prefer-logical-operator-over-ternary': [2],
|
||||
'unicorn/prefer-map-from-entries': [0],
|
||||
'unicorn/prefer-math-abs': [2],
|
||||
'unicorn/prefer-math-constants': [2],
|
||||
'unicorn/prefer-math-min-max': [2],
|
||||
'unicorn/prefer-math-trunc': [2],
|
||||
'unicorn/prefer-minimal-ternary': [0],
|
||||
'unicorn/prefer-modern-dom-apis': [0],
|
||||
'unicorn/prefer-modern-math-apis': [2],
|
||||
'unicorn/prefer-module': [2],
|
||||
'unicorn/prefer-native-coercion-functions': [2],
|
||||
'unicorn/prefer-negative-index': [2],
|
||||
'unicorn/prefer-node-protocol': [2],
|
||||
'unicorn/prefer-number-coercion': [0],
|
||||
'unicorn/prefer-number-is-safe-integer': [0],
|
||||
'unicorn/prefer-number-properties': [0],
|
||||
'unicorn/prefer-object-define-properties': [2],
|
||||
'unicorn/prefer-object-destructuring-defaults': [2],
|
||||
'unicorn/prefer-object-from-entries': [2],
|
||||
'unicorn/prefer-object-has-own': [0],
|
||||
'unicorn/prefer-object-iterable-methods': [2],
|
||||
'unicorn/prefer-optional-catch-binding': [2],
|
||||
'unicorn/prefer-path2d': [2],
|
||||
'unicorn/prefer-private-class-fields': [0],
|
||||
'unicorn/prefer-promise-with-resolvers': [2],
|
||||
'unicorn/prefer-prototype-methods': [0],
|
||||
'unicorn/prefer-query-selector': [2],
|
||||
'unicorn/prefer-queue-microtask': [2],
|
||||
'unicorn/prefer-reflect-apply': [0],
|
||||
'unicorn/prefer-regexp-escape': [0],
|
||||
'unicorn/prefer-regexp-test': [2],
|
||||
'unicorn/prefer-response-static-json': [2],
|
||||
'unicorn/prefer-scoped-selector': [0],
|
||||
'unicorn/prefer-set-has': [0],
|
||||
'unicorn/prefer-set-size': [2],
|
||||
'unicorn/prefer-short-arrow-method': [2],
|
||||
'unicorn/prefer-simple-condition-first': [0],
|
||||
'unicorn/prefer-simple-sort-comparator': [2],
|
||||
'unicorn/prefer-single-array-predicate': [2],
|
||||
'unicorn/prefer-single-call': [2],
|
||||
'unicorn/prefer-single-object-destructuring': [2],
|
||||
'unicorn/prefer-single-replace': [2],
|
||||
'unicorn/prefer-smaller-scope': [2],
|
||||
'unicorn/prefer-split-limit': [0], // too opinionated
|
||||
'unicorn/prefer-spread': [0],
|
||||
'unicorn/prefer-string-match-all': [2],
|
||||
'unicorn/prefer-string-pad-start-end': [2],
|
||||
'unicorn/prefer-string-raw': [0],
|
||||
'unicorn/prefer-string-repeat': [2],
|
||||
'unicorn/prefer-string-replace-all': [0],
|
||||
'unicorn/prefer-string-slice': [0],
|
||||
'unicorn/prefer-string-starts-ends-with': [0], // handled by @typescript-eslint/prefer-string-starts-ends-with
|
||||
'unicorn/prefer-string-trim-start-end': [2],
|
||||
'unicorn/prefer-structured-clone': [2],
|
||||
'unicorn/prefer-switch': [0],
|
||||
'unicorn/prefer-temporal': [0],
|
||||
'unicorn/prefer-ternary': [0],
|
||||
'unicorn/prefer-top-level-await': [0],
|
||||
'unicorn/prefer-type-error': [0],
|
||||
'unicorn/prefer-type-literal-last': [0],
|
||||
'unicorn/prefer-uint8array-base64': [0],
|
||||
'unicorn/prefer-unary-minus': [2],
|
||||
'unicorn/prefer-unicode-code-point-escapes': [0],
|
||||
'unicorn/prefer-url-can-parse': [2],
|
||||
'unicorn/prefer-url-href': [2],
|
||||
'unicorn/prefer-while-loop-condition': [2],
|
||||
'unicorn/prevent-abbreviations': [0],
|
||||
'unicorn/relative-url-style': [2],
|
||||
'unicorn/require-array-join-separator': [2],
|
||||
'unicorn/require-array-sort-compare': [0],
|
||||
'unicorn/require-css-escape': [2],
|
||||
'unicorn/require-module-attributes': [2],
|
||||
'unicorn/require-module-specifiers': [0],
|
||||
'unicorn/require-number-to-fixed-digits-argument': [2],
|
||||
'unicorn/require-passive-events': [2],
|
||||
'unicorn/require-post-message-target-origin': [0],
|
||||
'unicorn/require-proxy-trap-boolean-return': [2],
|
||||
'unicorn/string-content': [0],
|
||||
'unicorn/switch-case-braces': [0],
|
||||
'unicorn/switch-case-break-position': [2],
|
||||
'unicorn/template-indent': [2],
|
||||
'unicorn/text-encoding-identifier-case': [0],
|
||||
'unicorn/throw-new-error': [2],
|
||||
'unicorn/try-complexity': [0],
|
||||
'use-isnan': [2],
|
||||
'valid-typeof': [2, {requireStringLiterals: true}],
|
||||
'vars-on-top': [0],
|
||||
@@ -956,6 +1091,7 @@ export default defineConfig([
|
||||
languageOptions: {globals: globals.vitest},
|
||||
rules: {
|
||||
'gitea/unescaped-html-literal': [0],
|
||||
'unicorn/no-error-property-assignment': [0],
|
||||
'vitest/consistent-test-filename': [0],
|
||||
'vitest/consistent-test-it': [0],
|
||||
'vitest/expect-expect': [0],
|
||||
@@ -967,7 +1103,6 @@ export default defineConfig([
|
||||
'vitest/no-conditional-in-test': [0],
|
||||
'vitest/no-conditional-tests': [0],
|
||||
'vitest/no-disabled-tests': [0],
|
||||
'vitest/no-done-callback': [0],
|
||||
'vitest/no-duplicate-hooks': [0],
|
||||
'vitest/no-focused-tests': [2],
|
||||
'vitest/no-hooks': [0],
|
||||
|
||||
@@ -23,17 +23,17 @@ require (
|
||||
github.com/ProtonMail/go-crypto v1.4.1
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0
|
||||
github.com/alecthomas/chroma/v2 v2.26.1
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24
|
||||
github.com/aws/aws-sdk-go-v2/service/codecommit v1.34.4
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
|
||||
github.com/blevesearch/bleve/v2 v2.6.0
|
||||
github.com/bohde/codel v0.2.0
|
||||
github.com/buildkite/terminal-to-html/v3 v3.16.8
|
||||
github.com/caddyserver/certmagic v0.25.3
|
||||
github.com/caddyserver/certmagic v0.25.4
|
||||
github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20260309112543-12416315a635
|
||||
github.com/chi-middleware/proxy v1.1.1
|
||||
github.com/dlclark/regexp2/v2 v2.2.1
|
||||
github.com/dlclark/regexp2/v2 v2.2.2
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/editorconfig/editorconfig-core-go/v2 v2.6.4
|
||||
@@ -73,7 +73,7 @@ require (
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/markbates/goth v1.82.0
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mattn/go-sqlite3 v1.14.45
|
||||
github.com/mattn/go-sqlite3 v1.14.47
|
||||
github.com/meilisearch/meilisearch-go v0.36.3
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
@@ -86,7 +86,7 @@ require (
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/quasoft/websspi v1.1.2
|
||||
github.com/redis/go-redis/v9 v9.20.0
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
github.com/sassoftware/go-rpmutils v0.4.0
|
||||
@@ -96,15 +96,15 @@ require (
|
||||
github.com/tstranex/u2f v1.0.0
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/urfave/cli-docs/v3 v3.1.0
|
||||
github.com/urfave/cli/v3 v3.9.1
|
||||
github.com/urfave/cli/v3 v3.10.0
|
||||
github.com/wneessen/go-mail v0.7.3
|
||||
github.com/yohcop/openid-go v1.0.1
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.38.0
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.42.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.5
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
@@ -114,7 +114,7 @@ require (
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/ini.v1 v1.67.3
|
||||
modernc.org/sqlite v1.52.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
strk.kbt.io/projects/go/libravatar v0.0.0-20260301104140-add494e31dab
|
||||
xorm.io/builder v0.3.13
|
||||
@@ -273,7 +273,7 @@ require (
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.73.0 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -76,8 +76,8 @@ github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:I28hc9eaiqK
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
|
||||
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
@@ -187,8 +187,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/buildkite/terminal-to-html/v3 v3.16.8 h1:QN/daUob6cmK8GcdKnwn9+YTlPr1vNj+oeAIiJK6fPc=
|
||||
github.com/buildkite/terminal-to-html/v3 v3.16.8/go.mod h1:+k1KVKROZocrTLsEQ9PEf9A+8+X8uaVV5iO1ZIOwKYM=
|
||||
github.com/caddyserver/certmagic v0.25.3 h1:mGf5ba8F7xA4c5jfDZZbK2buY1VEkbnwpMDixaju94A=
|
||||
github.com/caddyserver/certmagic v0.25.3/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA=
|
||||
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
|
||||
github.com/caddyserver/certmagic v0.25.4/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA=
|
||||
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
|
||||
@@ -240,8 +240,8 @@ github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
@@ -514,8 +514,8 @@ github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/a
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-shellwords v1.0.13 h1:DC0OMEpGjm6LfNFU4ckYcvbQKyp2vE8atyFGXNtDcf4=
|
||||
github.com/mattn/go-shellwords v1.0.13/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
|
||||
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
|
||||
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/meilisearch/meilisearch-go v0.36.3 h1:Yx1aTY5jDgtbStPVkhJTDoLnZTy5sejQSPyjfNMy6e4=
|
||||
github.com/meilisearch/meilisearch-go v0.36.3/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
|
||||
github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
|
||||
@@ -618,8 +618,8 @@ github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4Ul
|
||||
github.com/quasoft/websspi v1.1.2 h1:/mA4w0LxWlE3novvsoEL6BBA1WnjJATbjkh1kFrTidw=
|
||||
github.com/quasoft/websspi v1.1.2/go.mod h1:HmVdl939dQ0WIXZhyik+ARdI03M6bQzaSEKcgpFmewk=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
|
||||
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
|
||||
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/redis/rueidis v1.0.71 h1:pODtnAR5GAB7j4ekhldZ29HKOxe4Hph0GTDGk1ayEQY=
|
||||
github.com/redis/rueidis v1.0.71/go.mod h1:lfdcZzJ1oKGKL37vh9fO3ymwt+0TdjkkUCJxbgpmcgQ=
|
||||
github.com/redis/rueidis/rueidiscompat v1.0.71 h1:wNZ//kEjMZgBM0KCk7ncOX8KmAgROU2kDdDNpwheG4w=
|
||||
@@ -709,8 +709,8 @@ github.com/unknwon/com v1.0.1 h1:3d1LTxD+Lnf3soQiD4Cp/0BRB+Rsa/+RTvz8GMMzIXs=
|
||||
github.com/unknwon/com v1.0.1/go.mod h1:tOOxU81rwgoCLoOVVPHb6T/wt8HZygqH5id+GNnlCXM=
|
||||
github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw=
|
||||
github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to=
|
||||
github.com/urfave/cli/v3 v3.9.1 h1:OLU13atWZ0M+a4xmyBuBNOLZsSRYXyPeMeNjOvgYP54=
|
||||
github.com/urfave/cli/v3 v3.9.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM=
|
||||
github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
|
||||
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
|
||||
@@ -740,8 +740,8 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.38.0 h1:gZSMTTnLcUeY5mH4z3G6GEzbaBTOCUfBCAJXMRyuzEM=
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.38.0/go.mod h1:SKUbKSS59KPt6WeGNJoYF8HDaf/rFMUSITlftj/HkLg=
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.42.0 h1:Bq5YIYgUJVbt4Hbh7ibBwNR4SNEafsyDVhIXl7dXDdg=
|
||||
gitlab.com/gitlab-org/api/client-go/v2 v2.42.0/go.mod h1:SKUbKSS59KPt6WeGNJoYF8HDaf/rFMUSITlftj/HkLg=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
@@ -780,8 +780,8 @@ golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -940,8 +940,8 @@ modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.0 h1:Y/KmTxbIN5T3x+NFjYOzV/+Ha7wKClfIecmTCTuYlqQ=
|
||||
modernc.org/libc v1.73.0/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -950,8 +950,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
+22
-11
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +22,8 @@ import (
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ActionRun represents a run of a workflow file
|
||||
@@ -48,6 +51,13 @@ type ActionRun struct {
|
||||
Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed
|
||||
RawConcurrency string // raw concurrency
|
||||
|
||||
// WorkflowRepoID/WorkflowCommitSHA record the (repo, commit) the run's workflow file content came from.
|
||||
// Always filled (repo-level run = the repo itself; scoped run = the source repo).
|
||||
WorkflowRepoID int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
WorkflowCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"`
|
||||
|
||||
IsScopedRun bool `xorm:"NOT NULL DEFAULT false"` // IsScopedRun explicitly classifies scoped runs.
|
||||
|
||||
// Started and Stopped are identical to the latest attempt after ActionRunAttempt was introduced.
|
||||
// When a rerun creates a new latest attempt, they are reset until the new attempt starts and stops.
|
||||
Started timeutil.TimeStamp
|
||||
@@ -86,7 +96,11 @@ func (run *ActionRun) WorkflowLink() string {
|
||||
if run.Repo == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), run.WorkflowID)
|
||||
// A scoped run's workflow is disambiguated by its source repo, so carry scoped_workflow_source_repo_id back to the run list
|
||||
if run.IsScopedRun {
|
||||
return fmt.Sprintf("%s/actions/?workflow=%s&scoped_workflow_source_repo_id=%d", run.Repo.Link(), url.QueryEscape(run.WorkflowID), run.WorkflowRepoID)
|
||||
}
|
||||
return fmt.Sprintf("%s/actions/?workflow=%s", run.Repo.Link(), url.QueryEscape(run.WorkflowID))
|
||||
}
|
||||
|
||||
// RefLink return the url of run's ref
|
||||
@@ -107,7 +121,7 @@ func (run *ActionRun) PrettyRef() string {
|
||||
return refName.ShortName()
|
||||
}
|
||||
|
||||
// RefTooltip return a tooltop of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
|
||||
// RefTooltip return a tooltip of run's ref. For pull request, it's the title of the PR, otherwise it's the ShortName.
|
||||
func (run *ActionRun) RefTooltip() string {
|
||||
payload, err := run.GetPullRequestEventPayload()
|
||||
if err == nil && payload != nil && payload.PullRequest != nil {
|
||||
@@ -264,11 +278,7 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
|
||||
}
|
||||
|
||||
func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionRun, error) {
|
||||
run := &ActionRun{
|
||||
RepoID: repoID,
|
||||
Index: runIndex,
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(run)
|
||||
run, has, err := db.Get[ActionRun](ctx, builder.Eq{"repo_id": repoID, "`index`": runIndex})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
@@ -279,9 +289,7 @@ func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionR
|
||||
}
|
||||
|
||||
func GetLatestRun(ctx context.Context, repoID int64) (*ActionRun, error) {
|
||||
run := &ActionRun{
|
||||
RepoID: repoID,
|
||||
}
|
||||
run := &ActionRun{}
|
||||
has, err := db.GetEngine(ctx).Where("repo_id=?", repoID).Desc("index").Get(run)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -295,7 +303,10 @@ func GetWorkflowLatestRun(ctx context.Context, repoID int64, workflowFile, branc
|
||||
var run ActionRun
|
||||
q := db.GetEngine(ctx).Where("repo_id=?", repoID).
|
||||
And("ref = ?", branch).
|
||||
And("workflow_id = ?", workflowFile)
|
||||
And("workflow_id = ?", workflowFile).
|
||||
// TODO: the badge only reflects the repo's own (repo-level) runs; a same-named scoped run must not leak in.
|
||||
// Support a scoped-workflow badge later by making this source-aware.
|
||||
And("is_scoped_run = ?", false)
|
||||
if event != "" {
|
||||
q.And("event = ?", event)
|
||||
}
|
||||
|
||||
+48
-10
@@ -108,6 +108,10 @@ type ActionRunJob struct {
|
||||
// ParentJobID scopes `Needs` resolution: name lookups happen only among rows sharing the same ParentJobID. 0 for top-level rows.
|
||||
ParentJobID int64 `xorm:"index NOT NULL DEFAULT 0"`
|
||||
|
||||
// ContinueOnError mirrors the job-level continue-on-error field from the workflow YAML.
|
||||
// When true, a failure of this job does not fail the overall workflow run.
|
||||
ContinueOnError bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
|
||||
Started timeutil.TimeStamp
|
||||
Stopped timeutil.TimeStamp
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
@@ -357,6 +361,14 @@ func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*
|
||||
return out
|
||||
}
|
||||
|
||||
// hasWaitingJobsToPick reports whether any waiting, unclaimed, non-reusable job
|
||||
// remains in the repo, i.e. work that an idle runner could still pick up.
|
||||
func hasWaitingJobsToPick(ctx context.Context, repoID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND task_id = ? AND status = ? AND is_reusable_caller = ?", repoID, 0, StatusWaiting, false).
|
||||
Exist(&ActionRunJob{})
|
||||
}
|
||||
|
||||
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
@@ -381,14 +393,6 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// Reusable workflow caller jobs are never picked up by runners, so they don't need a task-version bump.
|
||||
if statusUpdated && job.Status.IsWaiting() && !job.IsReusableCaller {
|
||||
// if the status of job changes to waiting again, increase tasks version.
|
||||
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if job.RunID == 0 {
|
||||
var err error
|
||||
if job, err = GetRunJobByRepoAndID(ctx, job.RepoID, job.ID); err != nil {
|
||||
@@ -396,6 +400,37 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
||||
}
|
||||
}
|
||||
|
||||
// Reusable workflow caller jobs are never picked up by runners, so they don't need a task-version bump.
|
||||
if statusUpdated && !job.IsReusableCaller {
|
||||
switch {
|
||||
case job.Status.IsWaiting():
|
||||
// A job returning to the waiting queue is work a runner can pick up, so bump the
|
||||
// version to wake idle runners whose tasksVersion already equals latestVersion.
|
||||
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
case job.Status.IsDone():
|
||||
// When a job finishes, bump the version so that idle runners — whose
|
||||
// tasksVersion already equals the current latestVersion — learn that
|
||||
// remaining waiting jobs are still available and attempt PickTask again.
|
||||
// Without this bump, runners that completed their tasks would see
|
||||
// tasksVersion==latestVersion and skip PickTask, leaving the other jobs
|
||||
// permanently unassigned until the version changes for another reason.
|
||||
// Only bump when waiting work actually remains for this repo, otherwise
|
||||
// every job completion would needlessly bump the global version and wake
|
||||
// every idle runner instance-wide for nothing.
|
||||
hasWaiting, err := hasWaitingJobsToPick(ctx, job.RepoID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if hasWaiting {
|
||||
if err := IncreaseTaskVersion(ctx, job.OwnerID, job.RepoID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if statusUpdated && job.ParentJobID > 0 {
|
||||
// Reusable workflow caller's children cascade their status changes upward to the parent caller.
|
||||
parent, err := GetRunJobByRunAndID(ctx, job.RunID, job.ParentJobID)
|
||||
@@ -500,9 +535,12 @@ func AggregateJobStatus(jobs []*ActionRunJob) Status {
|
||||
allSkipped := len(jobs) != 0
|
||||
var hasFailure, hasCancelled, hasCancelling, hasWaiting, hasRunning, hasBlocked bool
|
||||
for _, job := range jobs {
|
||||
allSuccessOrSkipped = allSuccessOrSkipped && (job.Status == StatusSuccess || job.Status == StatusSkipped)
|
||||
// A failed job with continue-on-error:true does not fail the workflow run.
|
||||
// It counts as a "continued failure" and is treated like success for aggregation.
|
||||
isContinuedFailure := job.ContinueOnError && job.Status == StatusFailure
|
||||
allSuccessOrSkipped = allSuccessOrSkipped && (job.Status == StatusSuccess || job.Status == StatusSkipped || isContinuedFailure)
|
||||
allSkipped = allSkipped && job.Status == StatusSkipped
|
||||
hasFailure = hasFailure || job.Status == StatusFailure
|
||||
hasFailure = hasFailure || (job.Status == StatusFailure && !job.ContinueOnError)
|
||||
hasCancelled = hasCancelled || job.Status == StatusCancelled
|
||||
hasCancelling = hasCancelling || job.Status == StatusCancelling
|
||||
hasWaiting = hasWaiting || job.Status == StatusWaiting
|
||||
|
||||
@@ -99,6 +99,10 @@ type FindRunJobOptions struct {
|
||||
UpdatedBefore timeutil.TimeStamp
|
||||
ConcurrencyGroup string
|
||||
OrderBy db.SearchOrderBy
|
||||
// AccessibleRepoIDsSubQuery, when non-nil, restricts results to the repo IDs selected by the
|
||||
// subquery (the caller's accessible repos). A nil value means no restriction. Using a subquery
|
||||
// instead of a materialized ID slice avoids exceeding DB parameter limits for large owners.
|
||||
AccessibleRepoIDsSubQuery *builder.Builder
|
||||
}
|
||||
|
||||
var JobOrderByMap = map[string]map[string]db.SearchOrderBy{
|
||||
@@ -132,6 +136,9 @@ func (opts FindRunJobOptions) ToConds() builder.Cond {
|
||||
}
|
||||
cond = cond.And(builder.Eq{"`action_run_job`.concurrency_group": opts.ConcurrencyGroup})
|
||||
}
|
||||
if opts.AccessibleRepoIDsSubQuery != nil {
|
||||
cond = cond.And(builder.In("`action_run_job`.repo_id", opts.AccessibleRepoIDsSubQuery))
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/translation"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
|
||||
@@ -61,12 +62,18 @@ type FindRunOptions struct {
|
||||
RepoID int64
|
||||
OwnerID int64
|
||||
WorkflowID string
|
||||
Ref string // the commit/tag/… that caused this workflow
|
||||
WorkflowRepoID int64 // source-aware filter: the repo a run's workflow content came from (0 = any)
|
||||
IsScopedRun optional.Option[bool] // is the run from a scoped workflow
|
||||
Ref string // the commit/tag/… that caused this workflow
|
||||
TriggerUserID int64
|
||||
TriggerEvent webhook_module.HookEventType
|
||||
Status []Status
|
||||
ConcurrencyGroup string
|
||||
CommitSHA string
|
||||
// AccessibleRepoIDsSubQuery, when non-nil, restricts results to the repo IDs selected by the
|
||||
// subquery (the caller's accessible repos). A nil value means no restriction. Using a subquery
|
||||
// instead of a materialized ID slice avoids exceeding DB parameter limits for large owners.
|
||||
AccessibleRepoIDsSubQuery *builder.Builder
|
||||
}
|
||||
|
||||
func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
@@ -77,6 +84,12 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.WorkflowID != "" {
|
||||
cond = cond.And(builder.Eq{"`action_run`.workflow_id": opts.WorkflowID})
|
||||
}
|
||||
if opts.WorkflowRepoID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run`.workflow_repo_id": opts.WorkflowRepoID})
|
||||
}
|
||||
if opts.IsScopedRun.Has() {
|
||||
cond = cond.And(builder.Eq{"`action_run`.is_scoped_run": opts.IsScopedRun.Value()})
|
||||
}
|
||||
if opts.TriggerUserID > 0 {
|
||||
cond = cond.And(builder.Eq{"`action_run`.trigger_user_id": opts.TriggerUserID})
|
||||
}
|
||||
@@ -92,6 +105,9 @@ func (opts FindRunOptions) ToConds() builder.Cond {
|
||||
if opts.CommitSHA != "" {
|
||||
cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA})
|
||||
}
|
||||
if opts.AccessibleRepoIDsSubQuery != nil {
|
||||
cond = cond.And(builder.In("`action_run`.repo_id", opts.AccessibleRepoIDsSubQuery))
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
@@ -106,6 +122,15 @@ func (opts FindRunOptions) ToJoins() []db.JoinFunc {
|
||||
}
|
||||
|
||||
func (opts FindRunOptions) ToOrders() string {
|
||||
// When scoped to a repo, sort by `index`: it reuses the unique
|
||||
// `repo_index` (repo_id, index) index, so the query seeks repo_id and
|
||||
// walks index descending instead of filesorting all matching rows.
|
||||
// Within a repo `index` is co-monotonic with `id`, so the order is the same.
|
||||
if opts.RepoID > 0 {
|
||||
return "`action_run`.`index` DESC"
|
||||
}
|
||||
// `index` is scoped per repo, so it is meaningless across repos. With no
|
||||
// RepoID, sort by the global, PK-indexed `id` for a deterministic order.
|
||||
return "`action_run`.`id` DESC"
|
||||
}
|
||||
|
||||
@@ -117,8 +142,8 @@ type StatusInfo struct {
|
||||
|
||||
// GetStatusInfoList returns a slice of StatusInfo
|
||||
func GetStatusInfoList(ctx context.Context, lang translation.Locale) []StatusInfo {
|
||||
// same as those in aggregateJobStatus
|
||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusWaiting, StatusRunning, StatusCancelling}
|
||||
// same as those in aggregateJobStatus (StatusUnknown excluded; it's the "shouldn't happen" fallback)
|
||||
allStatus := []Status{StatusSuccess, StatusFailure, StatusCancelled, StatusSkipped, StatusWaiting, StatusRunning, StatusBlocked, StatusCancelling}
|
||||
statusInfoList := make([]StatusInfo, 0, len(allStatus))
|
||||
for _, s := range allStatus {
|
||||
statusInfoList = append(statusInfoList, StatusInfo{
|
||||
@@ -147,9 +172,20 @@ func GetRunBranches(ctx context.Context, repoID int64) ([]string, error) {
|
||||
// GetRunWorkflowIDs returns all distinct WorkflowIDs that have at least
|
||||
// one ActionRun in the given repo.
|
||||
func GetRunWorkflowIDs(ctx context.Context, repoID int64) ([]string, error) {
|
||||
return getRunWorkflowIDs(ctx, repoID, builder.NewCond())
|
||||
}
|
||||
|
||||
// GetRepoRunWorkflowIDs returns all distinct WorkflowIDs that have at least
|
||||
// one repo-level ActionRun in the given repo.
|
||||
func GetRepoRunWorkflowIDs(ctx context.Context, repoID int64) ([]string, error) {
|
||||
return getRunWorkflowIDs(ctx, repoID, builder.Eq{"is_scoped_run": false})
|
||||
}
|
||||
|
||||
func getRunWorkflowIDs(ctx context.Context, repoID int64, extraCond builder.Cond) ([]string, error) {
|
||||
ids := make([]string, 0, 10)
|
||||
cond := builder.Eq{"repo_id": repoID}
|
||||
return ids, db.GetEngine(ctx).Table("action_run").
|
||||
Where(builder.Eq{"repo_id": repoID}).
|
||||
Where(cond.And(extraCond)).
|
||||
Distinct("workflow_id").
|
||||
Cols("workflow_id").
|
||||
Asc("workflow_id").
|
||||
|
||||
@@ -6,10 +6,13 @@ package actions
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/translation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetRunWorkflowIDs(t *testing.T) {
|
||||
@@ -24,14 +27,139 @@ func TestGetRunWorkflowIDs(t *testing.T) {
|
||||
assert.Empty(t, ids)
|
||||
}
|
||||
|
||||
func TestGetRepoRunWorkflowIDs(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
repoID = int64(4)
|
||||
repoWorkflowID = "repo-orphan.yaml"
|
||||
scopedWorkflowID = "scoped-only.yaml"
|
||||
sharedWorkflowID = "shared-name.yaml"
|
||||
scopedWorkflowRepo = int64(111)
|
||||
)
|
||||
for _, spec := range []struct {
|
||||
id int64
|
||||
workflowID string
|
||||
workflowRepoID int64
|
||||
isScopedRun bool
|
||||
}{
|
||||
{99811, repoWorkflowID, repoID, false},
|
||||
{99812, scopedWorkflowID, scopedWorkflowRepo, true},
|
||||
{99813, sharedWorkflowID, repoID, false},
|
||||
{99814, sharedWorkflowID, scopedWorkflowRepo, true},
|
||||
} {
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: spec.id,
|
||||
Index: spec.id,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: spec.workflowID,
|
||||
WorkflowRepoID: spec.workflowRepoID,
|
||||
IsScopedRun: spec.isScopedRun,
|
||||
}))
|
||||
}
|
||||
|
||||
ids, err := GetRepoRunWorkflowIDs(t.Context(), repoID)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, ids, repoWorkflowID)
|
||||
assert.Contains(t, ids, sharedWorkflowID)
|
||||
assert.NotContains(t, ids, scopedWorkflowID)
|
||||
}
|
||||
|
||||
func TestGetStatusInfoList(t *testing.T) {
|
||||
statusInfoList := GetStatusInfoList(t.Context(), translation.MockLocale{})
|
||||
|
||||
assert.Equal(t, []StatusInfo{
|
||||
{Status: int(StatusSuccess), StatusName: StatusSuccess.String(), DisplayedStatus: "actions.status.success"},
|
||||
{Status: int(StatusFailure), StatusName: StatusFailure.String(), DisplayedStatus: "actions.status.failure"},
|
||||
{Status: int(StatusCancelled), StatusName: StatusCancelled.String(), DisplayedStatus: "actions.status.cancelled"},
|
||||
{Status: int(StatusSkipped), StatusName: StatusSkipped.String(), DisplayedStatus: "actions.status.skipped"},
|
||||
{Status: int(StatusWaiting), StatusName: StatusWaiting.String(), DisplayedStatus: "actions.status.waiting"},
|
||||
{Status: int(StatusRunning), StatusName: StatusRunning.String(), DisplayedStatus: "actions.status.running"},
|
||||
{Status: int(StatusBlocked), StatusName: StatusBlocked.String(), DisplayedStatus: "actions.status.blocked"},
|
||||
{Status: int(StatusCancelling), StatusName: StatusCancelling.String(), DisplayedStatus: "actions.status.cancelling"},
|
||||
}, statusInfoList)
|
||||
}
|
||||
|
||||
// TestFindRunOptions_WorkflowRepoID: two runs share the bare WorkflowID but come from different content-source repos;
|
||||
// the source-aware WorkflowRepoID filter must separate them.
|
||||
func TestFindRunOptions_WorkflowRepoID(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
repoID = int64(4)
|
||||
sourceA = int64(111)
|
||||
sourceB = int64(222)
|
||||
workflowID = "u3-shared.yaml"
|
||||
)
|
||||
for _, spec := range []struct{ id, workflowRepoID int64 }{
|
||||
{99801, sourceA},
|
||||
{99802, sourceB},
|
||||
} {
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: spec.id,
|
||||
Index: spec.id,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: workflowID,
|
||||
WorkflowRepoID: spec.workflowRepoID,
|
||||
IsScopedRun: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// no source filter -> both
|
||||
all, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, all, 2)
|
||||
|
||||
// filter by source A -> only the run whose content came from A
|
||||
onlyA, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, WorkflowRepoID: sourceA})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, onlyA, 1)
|
||||
assert.EqualValues(t, 99801, onlyA[0].ID)
|
||||
|
||||
// filter by source B -> only the run whose content came from B
|
||||
onlyB, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, WorkflowRepoID: sourceB})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, onlyB, 1)
|
||||
assert.EqualValues(t, 99802, onlyB[0].ID)
|
||||
}
|
||||
|
||||
func TestFindRunOptions_IsScopedRun(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
repoID = int64(4)
|
||||
workflowID = "scoped-flag.yaml"
|
||||
)
|
||||
for _, spec := range []struct {
|
||||
id int64
|
||||
scoped bool
|
||||
}{
|
||||
{99821, false},
|
||||
{99822, true},
|
||||
} {
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: spec.id,
|
||||
Index: spec.id,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: workflowID,
|
||||
WorkflowRepoID: repoID,
|
||||
IsScopedRun: spec.scoped,
|
||||
}))
|
||||
}
|
||||
|
||||
repoLevel, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, IsScopedRun: optional.Some(false)})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repoLevel, 1)
|
||||
assert.EqualValues(t, 99821, repoLevel[0].ID)
|
||||
|
||||
scoped, err := db.Find[ActionRun](t.Context(), FindRunOptions{RepoID: repoID, WorkflowID: workflowID, IsScopedRun: optional.Some(true)})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scoped, 1)
|
||||
assert.EqualValues(t, 99822, scoped[0].ID)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateRepoRunsNumbers(t *testing.T) {
|
||||
@@ -44,3 +45,57 @@ func TestActionRun_Duration_NonNegative(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, time.Duration(0), run.Duration())
|
||||
}
|
||||
|
||||
func TestActionRun_WorkflowLink(t *testing.T) {
|
||||
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
|
||||
|
||||
// a repo-level run links by file name only
|
||||
repoLevel := &ActionRun{Repo: repo, WorkflowID: "ci.yaml", WorkflowRepoID: repo.ID}
|
||||
assert.Equal(t, repo.Link()+"/actions/?workflow=ci.yaml", repoLevel.WorkflowLink())
|
||||
|
||||
// a scoped run carries its source repo id back, so the list stays filtered to that source
|
||||
scoped := &ActionRun{Repo: repo, WorkflowID: "ci.yaml", WorkflowRepoID: 42, IsScopedRun: true}
|
||||
assert.Equal(t, repo.Link()+"/actions/?workflow=ci.yaml&scoped_workflow_source_repo_id=42", scoped.WorkflowLink())
|
||||
}
|
||||
|
||||
func TestGetWorkflowLatestRun_RepoLevelOnly(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const (
|
||||
repoID = int64(4)
|
||||
workflowID = "badge-source-aware.yaml"
|
||||
ref = "refs/heads/main"
|
||||
)
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: 99811,
|
||||
Index: 99811,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: workflowID,
|
||||
Ref: ref,
|
||||
Event: "push",
|
||||
Status: StatusSuccess,
|
||||
WorkflowRepoID: repoID,
|
||||
WorkflowCommitSHA: "repo-level-sha",
|
||||
}))
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionRun{
|
||||
ID: 99812,
|
||||
Index: 99812,
|
||||
RepoID: repoID,
|
||||
OwnerID: 1,
|
||||
TriggerUserID: 1,
|
||||
WorkflowID: workflowID,
|
||||
Ref: ref,
|
||||
Event: "push",
|
||||
Status: StatusFailure,
|
||||
WorkflowRepoID: 111,
|
||||
WorkflowCommitSHA: "scoped-sha",
|
||||
IsScopedRun: true,
|
||||
}))
|
||||
|
||||
run, err := GetWorkflowLatestRun(t.Context(), repoID, workflowID, ref, "push")
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 99811, run.ID)
|
||||
assert.False(t, run.IsScopedRun)
|
||||
}
|
||||
|
||||
@@ -75,8 +75,28 @@ type ActionRunner struct {
|
||||
const (
|
||||
RunnerOfflineTime = time.Minute
|
||||
RunnerIdleTime = 10 * time.Second
|
||||
// RunnerHeartbeatInterval is how often last_online is persisted on poll.
|
||||
// Must stay well below RunnerOfflineTime so runners don't flap to offline.
|
||||
RunnerHeartbeatInterval = 30 * time.Second
|
||||
// RunnerActiveInterval is how often last_active is persisted while a runner
|
||||
// streams task updates and logs. Must stay well below RunnerIdleTime so a
|
||||
// busy runner keeps showing ACTIVE without a DB write on every RPC.
|
||||
RunnerActiveInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// ShouldPersistLastOnline reports whether last_online is stale enough to be
|
||||
// worth writing back. Avoids a DB write on every runner poll.
|
||||
func ShouldPersistLastOnline(last timeutil.TimeStamp, now time.Time) bool {
|
||||
return now.Sub(last.AsTime()) >= RunnerHeartbeatInterval
|
||||
}
|
||||
|
||||
// ShouldPersistLastActive reports whether last_active is stale enough to be
|
||||
// worth writing back. Avoids a DB write on every UpdateTask/UpdateLog RPC while
|
||||
// a runner is actively streaming logs.
|
||||
func ShouldPersistLastActive(last timeutil.TimeStamp, now time.Time) bool {
|
||||
return now.Sub(last.AsTime()) >= RunnerActiveInterval
|
||||
}
|
||||
|
||||
// BelongsToOwnerName before calling, should guarantee that all attributes are loaded
|
||||
func (r *ActionRunner) BelongsToOwnerName() string {
|
||||
if r.RepoID != 0 {
|
||||
@@ -251,21 +271,24 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
|
||||
}
|
||||
|
||||
func (opts FindRunnerOptions) ToOrders() string {
|
||||
// A unique tiebreaker (id) is appended so that runners sharing the same
|
||||
// last_online or name keep a deterministic order across paginated queries,
|
||||
// otherwise the same runner may appear on more than one page.
|
||||
switch opts.Sort {
|
||||
case "online":
|
||||
return "last_online DESC"
|
||||
return "last_online DESC, id ASC"
|
||||
case "offline":
|
||||
return "last_online ASC"
|
||||
return "last_online ASC, id ASC"
|
||||
case "alphabetically":
|
||||
return "name ASC"
|
||||
return "name ASC, id ASC"
|
||||
case "reversealphabetically":
|
||||
return "name DESC"
|
||||
return "name DESC, id ASC"
|
||||
case "newest":
|
||||
return "id DESC"
|
||||
case "oldest":
|
||||
return "id ASC"
|
||||
}
|
||||
return "last_online DESC"
|
||||
return "last_online DESC, id ASC"
|
||||
}
|
||||
|
||||
// GetRunnerByUUID returns a runner via uuid
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShouldPersistLastOnline(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
last timeutil.TimeStamp
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh, skip write",
|
||||
last: timeutil.TimeStamp(now.Add(-5 * time.Second).Unix()),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exactly at interval, write",
|
||||
last: timeutil.TimeStamp(now.Add(-RunnerHeartbeatInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stale, write",
|
||||
last: timeutil.TimeStamp(now.Add(-2 * RunnerHeartbeatInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero (never seen), write",
|
||||
last: 0,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShouldPersistLastOnline(tt.last, now))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPersistLastActive(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
last timeutil.TimeStamp
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh, skip write",
|
||||
last: timeutil.TimeStamp(now.Add(-1 * time.Second).Unix()),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "exactly at interval, write",
|
||||
last: timeutil.TimeStamp(now.Add(-RunnerActiveInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "stale, write",
|
||||
last: timeutil.TimeStamp(now.Add(-2 * RunnerActiveInterval).Unix()),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "zero (never seen), write",
|
||||
last: 0,
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, ShouldPersistLastActive(tt.last, now))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) {
|
||||
// Sorts on a non-unique column must end with the unique id tiebreaker so
|
||||
// pagination is deterministic; without it, runners sharing the same
|
||||
// last_online or name can appear on more than one page. Sorts already on
|
||||
// the unique id need no tiebreaker.
|
||||
expected := map[string]string{
|
||||
"": "last_online DESC, id ASC",
|
||||
"online": "last_online DESC, id ASC",
|
||||
"offline": "last_online ASC, id ASC",
|
||||
"alphabetically": "name ASC, id ASC",
|
||||
"reversealphabetically": "name DESC, id ASC",
|
||||
"newest": "id DESC",
|
||||
"oldest": "id ASC",
|
||||
}
|
||||
for sort, want := range expected {
|
||||
assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRunners_PaginationNoDuplicates(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// Create several runners that all share the same last_online value so the
|
||||
// primary sort key (last_online) is tied for all of them.
|
||||
const ownerID = 1000
|
||||
const count = 6
|
||||
for i := range count {
|
||||
runner := &ActionRunner{
|
||||
Name: "paginated-runner",
|
||||
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
|
||||
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
|
||||
OwnerID: ownerID,
|
||||
RepoID: 0,
|
||||
LastOnline: 42,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, runner))
|
||||
}
|
||||
|
||||
// Page through the runners and ensure every id is returned exactly once.
|
||||
seen := make(map[int64]int)
|
||||
const pageSize = 2
|
||||
for page := 1; ; page++ {
|
||||
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
|
||||
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if len(runners) == 0 {
|
||||
break
|
||||
}
|
||||
for _, r := range runners {
|
||||
seen[r.ID]++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
|
||||
for id, n := range seen {
|
||||
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ActionScopedWorkflowSource registers a repository as a source of scoped workflows, either for an owner (user/org) or for the whole instance.
|
||||
type ActionScopedWorkflowSource struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
|
||||
// OwnerID is the scope the source applies to: a user/org ID (applies to that owner's repos), or 0 for instance-level (applies to every repo).
|
||||
OwnerID int64 `xorm:"UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
|
||||
// SourceRepoID is the source repository providing the workflow files; always non-zero.
|
||||
SourceRepoID int64 `xorm:"INDEX UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
|
||||
|
||||
// WorkflowConfigs maps a workflow ID (entry name) to its merge-gate config.
|
||||
WorkflowConfigs map[string]*ScopedWorkflowConfig `xorm:"JSON TEXT 'workflow_configs'"`
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
// ScopedWorkflowConfig is one scoped workflow's config within a source registration.
|
||||
type ScopedWorkflowConfig struct {
|
||||
Required bool `json:"required"`
|
||||
Patterns []string `json:"patterns"` // the status-check patterns that must be present and pass, only effective when Required is true
|
||||
}
|
||||
|
||||
func init() {
|
||||
db.RegisterModel(new(ActionScopedWorkflowSource))
|
||||
}
|
||||
|
||||
// IsWorkflowRequired reports whether the given workflow ID (entry name) is marked required in this source.
|
||||
func (s *ActionScopedWorkflowSource) IsWorkflowRequired(workflowID string) bool {
|
||||
c, ok := s.WorkflowConfigs[workflowID]
|
||||
return ok && c.Required
|
||||
}
|
||||
|
||||
type FindScopedWorkflowSourceOpts struct {
|
||||
db.ListOptions
|
||||
OwnerIDs []int64
|
||||
SourceRepoID int64
|
||||
}
|
||||
|
||||
func (opts FindScopedWorkflowSourceOpts) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if len(opts.OwnerIDs) > 0 {
|
||||
cond = cond.And(builder.In("owner_id", opts.OwnerIDs))
|
||||
}
|
||||
if opts.SourceRepoID != 0 {
|
||||
cond = cond.And(builder.Eq{"source_repo_id": opts.SourceRepoID})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetEffectiveScopedWorkflowSources returns the scoped-workflow sources effective for a repo owned by repoOwnerID:
|
||||
// the owner's own sources plus instance-level (owner_id=0) sources.
|
||||
func GetEffectiveScopedWorkflowSources(ctx context.Context, repoOwnerID int64) ([]*ActionScopedWorkflowSource, error) {
|
||||
owners := []int64{0}
|
||||
if repoOwnerID != 0 {
|
||||
owners = append(owners, repoOwnerID)
|
||||
}
|
||||
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners})
|
||||
}
|
||||
|
||||
// IsScopedWorkflowSourceEffective reports whether sourceRepoID is a scoped-workflow source effective for a repo owned by repoOwnerID.
|
||||
func IsScopedWorkflowSourceEffective(ctx context.Context, repoOwnerID, sourceRepoID int64) (bool, error) {
|
||||
owners := []int64{0}
|
||||
if repoOwnerID != 0 {
|
||||
owners = append(owners, repoOwnerID)
|
||||
}
|
||||
return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners, SourceRepoID: sourceRepoID}.ToConds())
|
||||
}
|
||||
|
||||
// IsWorkflowRequiredInSources reports whether workflowID from sourceRepoID is required by any of the given sources.
|
||||
func IsWorkflowRequiredInSources(sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool {
|
||||
for _, s := range sources {
|
||||
if s.SourceRepoID == sourceRepoID && s.IsWorkflowRequired(workflowID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ScopedStatusContextPrefix returns the source-repo prefix that makes a scoped run's commit-status context distinct from same-named workflows.
|
||||
func ScopedStatusContextPrefix(ctx context.Context, sourceRepoID int64) string {
|
||||
if sourceRepo, err := repo_model.GetRepositoryByID(ctx, sourceRepoID); err == nil {
|
||||
return sourceRepo.FullName()
|
||||
}
|
||||
return fmt.Sprintf("scoped:%d", sourceRepoID)
|
||||
}
|
||||
|
||||
// IsScopedWorkflowRequired reports whether workflowID from sourceRepoID is required for a repo owned by consumerOwnerID.
|
||||
func IsScopedWorkflowRequired(ctx context.Context, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) {
|
||||
sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID), nil
|
||||
}
|
||||
|
||||
// IsScopedWorkflowOptedOutloads the consumer's effective sources then calls ScopedWorkflowOptedOut
|
||||
func IsScopedWorkflowOptedOut(ctx context.Context, cfg *repo_model.ActionsConfig, consumerOwnerID, sourceRepoID int64, workflowID string) (bool, error) {
|
||||
if !cfg.IsScopedWorkflowDisabled(sourceRepoID, workflowID) {
|
||||
return false, nil
|
||||
}
|
||||
sources, err := GetEffectiveScopedWorkflowSources(ctx, consumerOwnerID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ScopedWorkflowOptedOut(cfg, sources, sourceRepoID, workflowID), nil
|
||||
}
|
||||
|
||||
// ScopedWorkflowOptedOut reports whether a consumer's opt-out of (sourceRepoID, workflowID) is in effect.
|
||||
func ScopedWorkflowOptedOut(cfg *repo_model.ActionsConfig, sources []*ActionScopedWorkflowSource, sourceRepoID int64, workflowID string) bool {
|
||||
return !IsWorkflowRequiredInSources(sources, sourceRepoID, workflowID) && cfg.IsScopedWorkflowDisabled(sourceRepoID, workflowID)
|
||||
}
|
||||
|
||||
// GetScopedWorkflowSourcesByOwner returns the sources an owner (user/org, or 0 for instance) registered.
|
||||
func GetScopedWorkflowSourcesByOwner(ctx context.Context, ownerID int64) ([]*ActionScopedWorkflowSource, error) {
|
||||
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{ownerID}})
|
||||
}
|
||||
|
||||
// GetScopedWorkflowSource returns the (owner, repo) source registration or a NotExist error.
|
||||
func GetScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) (*ActionScopedWorkflowSource, error) {
|
||||
src := &ActionScopedWorkflowSource{}
|
||||
has, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Get(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, util.NewNotExistErrorf("scoped workflow source (owner %d, repo %d) does not exist", ownerID, repoID)
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// AddScopedWorkflowSource registers repoID as a source for ownerID (no-op if already registered).
|
||||
func AddScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error {
|
||||
exists, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
if err := db.Insert(ctx, &ActionScopedWorkflowSource{OwnerID: ownerID, SourceRepoID: repoID}); err != nil {
|
||||
// Re-check and treat an already-present row as the intended no-op.
|
||||
if exists, existErr := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Exist(new(ActionScopedWorkflowSource)); existErr == nil && exists {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetScopedWorkflowSourceConfigs replaces the per-workflow merge-gate configs (workflow ID -> config).
|
||||
func SetScopedWorkflowSourceConfigs(ctx context.Context, ownerID, repoID int64, configs map[string]*ScopedWorkflowConfig) error {
|
||||
_, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).
|
||||
Cols("workflow_configs").
|
||||
Update(&ActionScopedWorkflowSource{WorkflowConfigs: configs})
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveScopedWorkflowSource removes the (owner, repo) source registration.
|
||||
func RemoveScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Delete(new(ActionScopedWorkflowSource))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScopedWorkflowSource_IsWorkflowRequired(t *testing.T) {
|
||||
src := &ActionScopedWorkflowSource{WorkflowConfigs: map[string]*ScopedWorkflowConfig{
|
||||
"a.yml": {Required: true, Patterns: []string{"p"}},
|
||||
"b.yml": {Required: true, Patterns: []string{"p"}},
|
||||
"c.yml": {Required: false, Patterns: []string{"p"}}, // patterns kept as history, not required
|
||||
}}
|
||||
assert.True(t, src.IsWorkflowRequired("a.yml"))
|
||||
assert.True(t, src.IsWorkflowRequired("b.yml"))
|
||||
assert.False(t, src.IsWorkflowRequired("c.yml"), "config kept as history but not required")
|
||||
assert.False(t, src.IsWorkflowRequired("d.yml"))
|
||||
|
||||
empty := &ActionScopedWorkflowSource{}
|
||||
assert.False(t, empty.IsWorkflowRequired("a.yml"))
|
||||
}
|
||||
|
||||
func TestIsWorkflowRequiredInSources(t *testing.T) {
|
||||
// repo 100 registered twice (org optional + instance required).
|
||||
sources := []*ActionScopedWorkflowSource{
|
||||
{OwnerID: 2, SourceRepoID: 100, WorkflowConfigs: nil},
|
||||
{OwnerID: 0, SourceRepoID: 100, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true, Patterns: []string{"p"}}}},
|
||||
{OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true, Patterns: []string{"p"}}}},
|
||||
}
|
||||
|
||||
assert.True(t, IsWorkflowRequiredInSources(sources, 100, "a.yml"), "required at instance level wins over org optional")
|
||||
assert.False(t, IsWorkflowRequiredInSources(sources, 100, "z.yml"))
|
||||
assert.False(t, IsWorkflowRequiredInSources(sources, 200, "a.yml"), "a.yml is required for repo 100, not repo 200")
|
||||
assert.True(t, IsWorkflowRequiredInSources(sources, 200, "b.yml"))
|
||||
assert.False(t, IsWorkflowRequiredInSources(sources, 999, "a.yml"), "unknown source repo")
|
||||
}
|
||||
|
||||
func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
rows := []*ActionScopedWorkflowSource{
|
||||
{OwnerID: 2, SourceRepoID: 100, WorkflowConfigs: nil}, // org 2 registers repo 100 (optional)
|
||||
{OwnerID: 0, SourceRepoID: 100, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true, Patterns: []string{"p"}}}}, // instance also registers repo 100 (required)
|
||||
{OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true, Patterns: []string{"p"}}}}, // instance source 200
|
||||
{OwnerID: 3, SourceRepoID: 300, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true, Patterns: []string{"p"}}}}, // a different owner's source
|
||||
}
|
||||
for _, r := range rows {
|
||||
require.NoError(t, db.Insert(ctx, r))
|
||||
}
|
||||
|
||||
// owner 2 sees its own sources plus instance-level ones, but not owner 3's.
|
||||
owner2, err := GetEffectiveScopedWorkflowSources(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, owner2, 3)
|
||||
|
||||
required, err := IsScopedWorkflowRequired(ctx, 2, 100, "a.yml")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, required, "instance marks a.yml required → required for owner 2 even though org left it optional")
|
||||
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 100, "x.yml")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, required)
|
||||
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 200, "b.yml")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, required)
|
||||
|
||||
// owner 3's source must not be effective for owner 2.
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 300, "c.yml")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, required)
|
||||
|
||||
// IsScopedWorkflowSourceEffective: owner-level and instance-level sources are effective; another owner's is not.
|
||||
effective, err := IsScopedWorkflowSourceEffective(ctx, 2, 100)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "owner 2's own source")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 200)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "instance-level source is effective for any owner")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 300)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, effective, "owner 3's source is not effective for owner 2")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 999)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, effective, "unknown source repo")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 3, 300)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "owner 3's own source is effective for owner 3")
|
||||
}
|
||||
|
||||
func TestScopedWorkflowSourceCRUD(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// add is idempotent
|
||||
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10))
|
||||
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10))
|
||||
sources, err := GetScopedWorkflowSourcesByOwner(ctx, 5)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, sources, 1)
|
||||
|
||||
// set the per-workflow configs (entry name -> {required, patterns}); a.yml required, b.yml kept as history (not required)
|
||||
configs := map[string]*ScopedWorkflowConfig{
|
||||
"a.yml": {Required: true, Patterns: []string{"src: a.yml / *"}},
|
||||
"b.yml": {Required: false, Patterns: []string{"src: b.yml / build (push)"}},
|
||||
}
|
||||
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, configs))
|
||||
src, err := GetScopedWorkflowSource(ctx, 5, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configs, src.WorkflowConfigs)
|
||||
|
||||
// clearing the configs works
|
||||
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, nil))
|
||||
src, err = GetScopedWorkflowSource(ctx, 5, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, src.WorkflowConfigs)
|
||||
|
||||
// remove
|
||||
require.NoError(t, RemoveScopedWorkflowSource(ctx, 5, 10))
|
||||
_, err = GetScopedWorkflowSource(ctx, 5, 10)
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
sources, err = GetScopedWorkflowSourcesByOwner(ctx, 5)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, sources)
|
||||
}
|
||||
@@ -48,3 +48,57 @@ func TestStatusFromResult(t *testing.T) {
|
||||
assert.Equal(t, tt.want, StatusFromResult(tt.result), "result=%s", tt.result)
|
||||
}
|
||||
}
|
||||
|
||||
func newJob(status Status, continueOnError bool) *ActionRunJob {
|
||||
return &ActionRunJob{Status: status, ContinueOnError: continueOnError}
|
||||
}
|
||||
|
||||
func TestAggregateJobStatusContinueOnError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
jobs []*ActionRunJob
|
||||
want Status
|
||||
}{
|
||||
{
|
||||
name: "all success",
|
||||
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusSuccess, false)},
|
||||
want: StatusSuccess,
|
||||
},
|
||||
{
|
||||
name: "one failure without continue-on-error",
|
||||
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusFailure, false)},
|
||||
want: StatusFailure,
|
||||
},
|
||||
{
|
||||
name: "one failure with continue-on-error",
|
||||
jobs: []*ActionRunJob{newJob(StatusSuccess, false), newJob(StatusFailure, true)},
|
||||
want: StatusSuccess,
|
||||
},
|
||||
{
|
||||
name: "only continued-failure",
|
||||
jobs: []*ActionRunJob{newJob(StatusFailure, true)},
|
||||
want: StatusSuccess,
|
||||
},
|
||||
{
|
||||
name: "continued-failure plus real failure",
|
||||
jobs: []*ActionRunJob{newJob(StatusFailure, true), newJob(StatusFailure, false)},
|
||||
want: StatusFailure,
|
||||
},
|
||||
{
|
||||
name: "all skipped",
|
||||
jobs: []*ActionRunJob{newJob(StatusSkipped, false), newJob(StatusSkipped, false)},
|
||||
want: StatusSkipped,
|
||||
},
|
||||
{
|
||||
name: "continued-failure plus skipped counts as success",
|
||||
jobs: []*ActionRunJob{newJob(StatusFailure, true), newJob(StatusSkipped, false)},
|
||||
want: StatusSuccess,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, AggregateJobStatus(tt.jobs))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+206
-97
@@ -16,6 +16,7 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -227,13 +228,23 @@ func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
||||
return util.EllipsisDisplayString(name, limit) // database column has a length limit
|
||||
}
|
||||
|
||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
|
||||
ctx, committer, err := db.TxContext(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer committer.Close()
|
||||
// errJobAlreadyClaimed is a sentinel used inside claimJobForRunner to signal that
|
||||
// another runner won the optimistic-lock race; it is never returned to callers.
|
||||
var errJobAlreadyClaimed = errors.New("job already claimed by another runner")
|
||||
|
||||
// pickTaskBatchSize bounds how many waiting jobs each CreateTaskForRunner query loads,
|
||||
// so a large backlog is not fetched into memory on every runner poll.
|
||||
// It is a var only so tests can shrink it to exercise pagination cheaply.
|
||||
var pickTaskBatchSize = 100
|
||||
|
||||
// CreateTaskForRunner finds a waiting job that matches the runner's labels and
|
||||
// atomically claims it. It iterates through all matching jobs so that a
|
||||
// concurrent claim by another runner (which would lose the optimistic lock on
|
||||
// job #1) does not leave the remaining jobs permanently unassigned.
|
||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
|
||||
if db.InTransaction(ctx) {
|
||||
return nil, false, errors.New("CreateTaskForRunner must not be called within a database transaction")
|
||||
}
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
jobCond := builder.NewCond()
|
||||
@@ -244,93 +255,174 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
|
||||
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
|
||||
}
|
||||
if jobCond.IsValid() {
|
||||
jobCond = builder.In("run_id", builder.Select("id").From("action_run").Where(jobCond))
|
||||
}
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where("task_id=? AND status=? AND is_reusable_caller=?", 0, StatusWaiting, false).And(jobCond).Asc("updated", "id").Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
|
||||
|
||||
// TODO: a more efficient way to filter labels
|
||||
var job *ActionRunJob
|
||||
log.Trace("runner labels: %v", runner.AgentLabels)
|
||||
for _, v := range jobs {
|
||||
if runner.CanMatchLabels(v.RunsOn) {
|
||||
job = v
|
||||
break
|
||||
|
||||
// Page through the waiting jobs oldest-first instead of loading the whole backlog into memory on every poll.
|
||||
// Keyset pagination on (updated, id) is safe under concurrent claims:
|
||||
// updated only moves forward, so the advancing cursor never skips a still-waiting job even as claimed jobs drop out.
|
||||
var cursorUpdated timeutil.TimeStamp
|
||||
var cursorID int64
|
||||
for {
|
||||
cond := baseCond
|
||||
if cursorID > 0 {
|
||||
cond = cond.And(builder.Or(
|
||||
builder.Gt{"updated": cursorUpdated},
|
||||
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
||||
))
|
||||
}
|
||||
}
|
||||
if job == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
job.Started = now
|
||||
job.Status = StatusRunning
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: job.Attempt,
|
||||
RunnerID: runner.ID,
|
||||
Started: now,
|
||||
Status: StatusRunning,
|
||||
RepoID: job.RepoID,
|
||||
OwnerID: job.OwnerID,
|
||||
CommitSHA: job.CommitSHA,
|
||||
IsForkPullRequest: job.IsForkPullRequest,
|
||||
}
|
||||
task.GenerateAndFillToken()
|
||||
|
||||
workflowJob, err := job.ParseJob()
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("load job %d: %w", job.ID, err)
|
||||
}
|
||||
|
||||
if _, err := e.Insert(task); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
task.LogFilename = logFileName(job.Run.Repo.FullName(), task.ID)
|
||||
if err := UpdateTask(ctx, task, "log_filename"); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(workflowJob.Steps) > 0 {
|
||||
steps := make([]*ActionTaskStep, len(workflowJob.Steps))
|
||||
for i, v := range workflowJob.Steps {
|
||||
steps[i] = &ActionTaskStep{
|
||||
Name: makeTaskStepDisplayName(v, 255),
|
||||
TaskID: task.ID,
|
||||
Index: int64(i),
|
||||
RepoID: task.RepoID,
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
}
|
||||
if _, err := e.Insert(steps); err != nil {
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
task.Steps = steps
|
||||
}
|
||||
|
||||
job.TaskID = task.ID
|
||||
if n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0}); err != nil {
|
||||
return nil, false, err
|
||||
} else if n != 1 {
|
||||
for _, v := range jobs {
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
continue
|
||||
}
|
||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if ok {
|
||||
return task, true, nil
|
||||
}
|
||||
// Another runner claimed this job concurrently; try the next one.
|
||||
}
|
||||
|
||||
// A short page means no waiting jobs remain beyond it.
|
||||
if len(jobs) < pickTaskBatchSize {
|
||||
return nil, false, nil
|
||||
}
|
||||
last := jobs[len(jobs)-1]
|
||||
cursorUpdated, cursorID = last.Updated, last.ID
|
||||
}
|
||||
}
|
||||
|
||||
// claimJobForRunner attempts to atomically claim job for runner inside its own
|
||||
// transaction. Returns (task, true, nil) on success, or (nil, false, nil) when
|
||||
// another runner wins the optimistic-lock race (the caller should try the next
|
||||
// candidate job).
|
||||
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
|
||||
var resultTask *ActionTask
|
||||
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := timeutil.TimeStampNow()
|
||||
job.Started = now
|
||||
job.Status = StatusRunning
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.ID,
|
||||
Attempt: job.Attempt,
|
||||
RunnerID: runner.ID,
|
||||
Started: now,
|
||||
Status: StatusRunning,
|
||||
RepoID: job.RepoID,
|
||||
OwnerID: job.OwnerID,
|
||||
CommitSHA: job.CommitSHA,
|
||||
IsForkPullRequest: job.IsForkPullRequest,
|
||||
}
|
||||
task.GenerateAndFillToken()
|
||||
|
||||
workflowJob, err := job.ParseJob()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load job %d: %w", job.ID, err)
|
||||
}
|
||||
|
||||
if _, err := e.Insert(task); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task.LogFilename = logFileName(job.Run.Repo.FullName(), task.ID)
|
||||
if err := UpdateTask(ctx, task, "log_filename"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(workflowJob.Steps) > 0 {
|
||||
steps := make([]*ActionTaskStep, len(workflowJob.Steps))
|
||||
for i, v := range workflowJob.Steps {
|
||||
steps[i] = &ActionTaskStep{
|
||||
Name: makeTaskStepDisplayName(v, 255),
|
||||
TaskID: task.ID,
|
||||
Index: int64(i),
|
||||
RepoID: task.RepoID,
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
}
|
||||
if _, err := e.Insert(steps); err != nil {
|
||||
return err
|
||||
}
|
||||
task.Steps = steps
|
||||
}
|
||||
|
||||
job.TaskID = task.ID
|
||||
n, err := UpdateRunJob(ctx, job, builder.And(builder.Eq{"task_id": 0}, builder.Eq{"status": StatusWaiting}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
// Another runner claimed this job between our scan and this update;
|
||||
// signal the outer loop to move on without treating this as an error.
|
||||
return errJobAlreadyClaimed
|
||||
}
|
||||
|
||||
task.Job = job
|
||||
resultTask = task
|
||||
return nil
|
||||
})
|
||||
|
||||
if errors.Is(err, errJobAlreadyClaimed) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
task.Job = job
|
||||
|
||||
if err := committer.Commit(); err != nil {
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return resultTask, true, nil
|
||||
}
|
||||
|
||||
return task, true, nil
|
||||
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes
|
||||
// the task together with its steps and returns the job to the waiting queue. It is
|
||||
// used when assembling the runner response fails after the job was already claimed,
|
||||
// so the job is not stranded in running state with no runner ever executing it.
|
||||
func ReleaseTaskForRunner(ctx context.Context, task *ActionTask) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
job, err := GetRunJobByRepoAndID(ctx, task.RepoID, task.JobID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
job.Status = StatusWaiting
|
||||
job.Started = 0
|
||||
job.TaskID = 0
|
||||
// Guard on task_id and status so we only release while the job still
|
||||
// references this task and has not progressed past running.
|
||||
n, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": task.ID, "status": StatusRunning}, "status", "started", "task_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("release task %d: job %d no longer references it", task.ID, task.JobID)
|
||||
}
|
||||
|
||||
if _, err := e.Delete(&ActionTaskStep{TaskID: task.ID}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := e.ID(task.ID).Delete(&ActionTask{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
|
||||
@@ -347,6 +439,18 @@ func UpdateTask(ctx context.Context, task *ActionTask, cols ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func getRunIDByTaskID(ctx context.Context, taskID int64) (runID int64, _ error) {
|
||||
if has, err := db.GetEngine(ctx).Cols("action_run_job.run_id").
|
||||
Table("action_task").
|
||||
Join("INNER", "action_run_job", "action_run_job.id = action_task.job_id").
|
||||
Where(builder.Eq{"action_task.id": taskID}).Get(&runID); err != nil {
|
||||
return runID, err
|
||||
} else if !has {
|
||||
return runID, util.ErrNotExist
|
||||
}
|
||||
return runID, nil
|
||||
}
|
||||
|
||||
// UpdateTaskByState updates the task by the state.
|
||||
// It will always update the task if the state is not final, even there is no change.
|
||||
// So it will update ActionTask.Updated to avoid the task being judged as a zombie task.
|
||||
@@ -356,21 +460,26 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
stepStates[v.Id] = v
|
||||
}
|
||||
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*ActionTask, error) {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
task := &ActionTask{}
|
||||
if has, err := e.ID(state.Id).Get(task); err != nil {
|
||||
return nil, err
|
||||
// Only one request can update the task because the final state needs to be calculated with all job states.
|
||||
// Otherwise, concurrent requests with transaction will make the SQL read stale job state and result in wrong final state.
|
||||
taskID := state.Id
|
||||
runID, err := getRunIDByTaskID(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task := &ActionTask{}
|
||||
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
|
||||
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
return nil, util.ErrNotExist
|
||||
return util.ErrNotExist
|
||||
} else if runnerID != task.RunnerID {
|
||||
return nil, errors.New("invalid runner for task")
|
||||
return errors.New("invalid runner for task")
|
||||
}
|
||||
|
||||
if task.Status.IsDone() {
|
||||
// the state is final, do nothing
|
||||
return task, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// state.Result is not unspecified means the task is finished
|
||||
@@ -383,7 +492,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
}
|
||||
task.Stopped = timeutil.TimeStamp(state.StoppedAt.AsTime().Unix())
|
||||
if err := UpdateTask(ctx, task, "status", "stopped"); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if _, err := UpdateRunJob(ctx, &ActionRunJob{
|
||||
ID: task.JobID,
|
||||
@@ -391,18 +500,18 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
Status: task.Status,
|
||||
Stopped: task.Stopped,
|
||||
}, nil, "status", "stopped"); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Force update ActionTask.Updated to avoid the task being judged as a zombie task
|
||||
task.Updated = timeutil.TimeStampNow()
|
||||
if err := UpdateTask(ctx, task, "updated"); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := task.LoadAttributes(ctx); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
for _, step := range task.Steps {
|
||||
@@ -419,13 +528,13 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
} else if step.Started != 0 {
|
||||
step.Status = StatusRunning
|
||||
}
|
||||
if _, err := e.ID(step.ID).Update(step); err != nil {
|
||||
return nil, err
|
||||
if _, err := db.GetEngine(ctx).ID(step.ID).Update(step); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return task, nil
|
||||
return nil
|
||||
})
|
||||
return task, err
|
||||
}
|
||||
|
||||
func StopTask(ctx context.Context, taskID int64, status Status) error {
|
||||
|
||||
@@ -306,3 +306,139 @@ func TestStopTaskCancellingFallsBackForMissingRunner(t *testing.T) {
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
}
|
||||
|
||||
// TestReleaseTaskForRunner verifies that releasing a freshly-claimed task returns
|
||||
// its job to the waiting queue and deletes the task and its steps, so a failure
|
||||
// while assembling the runner response cannot strand the job in running state.
|
||||
func TestReleaseTaskForRunner(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "release-task-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 9902,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "release-job",
|
||||
Attempt: 1,
|
||||
JobID: "release-job",
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: []byte("on: push\njobs:\n release-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "release-runner-uuid",
|
||||
Name: "release-runner",
|
||||
AgentLabels: []string{"ubuntu-latest"},
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, task)
|
||||
|
||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
require.Equal(t, StatusRunning, claimed.Status)
|
||||
require.Equal(t, task.ID, claimed.TaskID)
|
||||
|
||||
require.NoError(t, ReleaseTaskForRunner(t.Context(), task))
|
||||
|
||||
// Job is back in the waiting queue with no task assigned.
|
||||
released := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusWaiting, released.Status)
|
||||
assert.Zero(t, released.TaskID)
|
||||
assert.Zero(t, released.Started)
|
||||
|
||||
// The task and its steps are gone.
|
||||
unittest.AssertNotExistsBean(t, &ActionTask{ID: task.ID})
|
||||
unittest.AssertNotExistsBean(t, &ActionTaskStep{TaskID: task.ID})
|
||||
}
|
||||
|
||||
// TestCreateTaskForRunnerPagination verifies that a job sitting beyond the first page is still claimed
|
||||
func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
defer func(orig int) { pickTaskBatchSize = orig }(pickTaskBatchSize)
|
||||
pickTaskBatchSize = 2
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "pagination-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 9903,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/main",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
// Five waiting jobs the runner cannot run, then one it can.
|
||||
// With a page size of 2 the matching job only appears on the third page.
|
||||
for i := range 5 {
|
||||
mismatch := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "mismatch-" + string(rune('a'+i)),
|
||||
Attempt: 1,
|
||||
JobID: "mismatch-" + string(rune('a'+i)),
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"windows-latest"},
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), mismatch))
|
||||
}
|
||||
|
||||
target := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "target-job",
|
||||
Attempt: 1,
|
||||
JobID: "target-job",
|
||||
Status: StatusWaiting,
|
||||
RunsOn: []string{"ubuntu-latest"},
|
||||
WorkflowPayload: []byte("on: push\njobs:\n target-job:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), target))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "pagination-runner-uuid",
|
||||
Name: "pagination-runner",
|
||||
AgentLabels: []string{"ubuntu-latest"},
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, task)
|
||||
|
||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
|
||||
assert.Equal(t, StatusRunning, claimed.Status)
|
||||
assert.Equal(t, task.ID, claimed.TaskID)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// Task represents a task
|
||||
@@ -172,17 +174,13 @@ func (err ErrTaskDoesNotExist) Unwrap() error {
|
||||
|
||||
// GetMigratingTask returns the migrating task by repo's id
|
||||
func GetMigratingTask(ctx context.Context, repoID int64) (*Task, error) {
|
||||
task := Task{
|
||||
RepoID: repoID,
|
||||
Type: structs.TaskTypeMigrateRepo,
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(&task)
|
||||
task, has, err := db.Get[Task](ctx, builder.Eq{"repo_id": repoID, "`type`": structs.TaskTypeMigrateRepo})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrTaskDoesNotExist{0, repoID, task.Type}
|
||||
return nil, ErrTaskDoesNotExist{0, repoID, structs.TaskTypeMigrateRepo}
|
||||
}
|
||||
return &task, nil
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// CreateTask creates a task on database
|
||||
|
||||
@@ -36,7 +36,18 @@ import (
|
||||
|
||||
const ssh2keyStart = "---- BEGIN SSH2 PUBLIC KEY ----"
|
||||
|
||||
const (
|
||||
// the longest RSA key ssh-keygen allows to generate is 16384 bits (2048 bytes), we still relax the limit a little here
|
||||
maxKeyBinaryBytes = 4096
|
||||
maxKeyContentBase64Bytes = maxKeyBinaryBytes * 4 / 3
|
||||
maxKeyContentExtraBytes = 4 * 1024 // header, footer, comment
|
||||
maxKeyContentBytes = maxKeyContentBase64Bytes + maxKeyContentExtraBytes
|
||||
)
|
||||
|
||||
func extractTypeFromBase64Key(key string) (string, error) {
|
||||
if len(key) > maxKeyContentBase64Bytes {
|
||||
return "", util.NewInvalidArgumentErrorf("SSH public key base64 is too long")
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(key)
|
||||
if err != nil || len(b) < 4 {
|
||||
return "", fmt.Errorf("invalid key format: %w", err)
|
||||
@@ -52,6 +63,10 @@ func extractTypeFromBase64Key(key string) (string, error) {
|
||||
|
||||
// parseKeyString parses any key string in OpenSSH or SSH2 format to clean OpenSSH string (RFC4253).
|
||||
func parseKeyString(content string) (string, error) {
|
||||
if len(content) > maxKeyContentBytes {
|
||||
return "", util.NewInvalidArgumentErrorf("SSH public key content is too long")
|
||||
}
|
||||
|
||||
// remove whitespace at start and end
|
||||
content = strings.TrimSpace(content)
|
||||
|
||||
@@ -63,6 +78,8 @@ func parseKeyString(content string) (string, error) {
|
||||
// Transform all legal line endings to a single "\n".
|
||||
content = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(content)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(content))
|
||||
lines := strings.Split(content, "\n")
|
||||
continuationLine := false
|
||||
|
||||
@@ -74,9 +91,10 @@ func parseKeyString(content string) (string, error) {
|
||||
if continuationLine || strings.ContainsAny(line, ":-") {
|
||||
continuationLine = strings.HasSuffix(line, "\\")
|
||||
} else {
|
||||
keyContent += line
|
||||
b.WriteString(line)
|
||||
}
|
||||
}
|
||||
keyContent = b.String()
|
||||
|
||||
t, err := extractTypeFromBase64Key(keyContent)
|
||||
if err != nil {
|
||||
|
||||
@@ -473,10 +473,20 @@ func runErr(t *testing.T, stdin []byte, args ...string) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_PublicKeysAreExternallyManaged(t *testing.T) {
|
||||
func TestPublicKeysAreExternallyManaged(t *testing.T) {
|
||||
key1 := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1})
|
||||
externals, err := PublicKeysAreExternallyManaged(t.Context(), []*PublicKey{key1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, externals, 1)
|
||||
assert.False(t, externals[0])
|
||||
}
|
||||
|
||||
// TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations.
|
||||
func TestCheckPublicKeyStringOversized(t *testing.T) {
|
||||
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
|
||||
assert.ErrorContains(t, err, "SSH public key content is too long")
|
||||
|
||||
content := "---- BEGIN SSH2 PUBLIC KEY ----\n" + strings.Repeat("a", maxKeyContentBase64Bytes+1) + "\n--- END SSH2 PUBLIC KEY ----"
|
||||
_, err = parseKeyString(content)
|
||||
assert.ErrorContains(t, err, "SSH public key base64 is too long")
|
||||
}
|
||||
|
||||
@@ -304,6 +304,36 @@ func (s AccessTokenScope) PublicOnly() (bool, error) {
|
||||
return bitmap.hasScope(AccessTokenScopePublicOnly)
|
||||
}
|
||||
|
||||
// CanCreateChildScope reports whether a request authenticated by this (parent) scope may mint a token
|
||||
// carrying the child scope. It rejects any grantable scope the parent does not hold, closing the
|
||||
// scope-escalation path. public-only is a restriction rather than a grantable permission, so it is
|
||||
// ignored here (a child may always be public-only); EnforcePublicOnlyFrom handles carrying it down.
|
||||
func (s AccessTokenScope) CanCreateChildScope(child AccessTokenScope) (bool, error) {
|
||||
requested := child.StringSlice()
|
||||
scopes := make([]AccessTokenScope, 0, len(requested))
|
||||
for _, sc := range requested {
|
||||
childScope := AccessTokenScope(sc)
|
||||
if childScope == AccessTokenScopePublicOnly {
|
||||
continue
|
||||
}
|
||||
scopes = append(scopes, childScope)
|
||||
}
|
||||
return s.HasScope(scopes...)
|
||||
}
|
||||
|
||||
// EnforcePublicOnlyFrom adds the public-only restriction to s when the authorizing parent scope is
|
||||
// public-only, so a public-only token cannot mint a child token that drops the restriction.
|
||||
func (s AccessTokenScope) EnforcePublicOnlyFrom(parent AccessTokenScope) (AccessTokenScope, error) {
|
||||
publicOnly, err := parent.PublicOnly()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !publicOnly {
|
||||
return s, nil
|
||||
}
|
||||
return AccessTokenScope(string(s) + "," + string(AccessTokenScopePublicOnly)).Normalize()
|
||||
}
|
||||
|
||||
// HasScope returns true if the string has the given scope
|
||||
func (s AccessTokenScope) HasScope(scopes ...AccessTokenScope) (bool, error) {
|
||||
bitmap, err := s.parse()
|
||||
|
||||
@@ -89,3 +89,26 @@ func TestAccessTokenScope_HasScope(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTokenScope_EnforcePublicOnlyFrom(t *testing.T) {
|
||||
tests := []struct {
|
||||
in AccessTokenScope
|
||||
parent AccessTokenScope
|
||||
out AccessTokenScope
|
||||
}{
|
||||
// public-only parent forces the restriction onto the minted scope
|
||||
{"write:user", "write:user,public-only", "public-only,write:user"},
|
||||
// already public-only stays public-only
|
||||
{"public-only,read:user", "public-only", "public-only,read:user"},
|
||||
// non-public-only parent leaves the scope untouched
|
||||
{"write:user", "write:user", "write:user"},
|
||||
{"all", "all", "all"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(string(test.parent)+"->"+string(test.in), func(t *testing.T) {
|
||||
got, err := test.in.EnforcePublicOnlyFrom(test.parent)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.out, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"strk.kbt.io/projects/go/libravatar"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -99,12 +100,13 @@ func HashEmail(email string) string {
|
||||
// GetEmailForHash converts a provided md5sum to the email
|
||||
func GetEmailForHash(ctx context.Context, md5Sum string) (string, error) {
|
||||
return cache.GetString("Avatar:"+md5Sum, func() (string, error) {
|
||||
emailHash := EmailHash{
|
||||
Hash: strings.ToLower(strings.TrimSpace(md5Sum)),
|
||||
emailHash, has, err := db.Get[EmailHash](ctx, builder.Eq{"`hash`": strings.ToLower(strings.TrimSpace(md5Sum))})
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if !has {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
_, err := db.GetEngine(ctx).Get(&emailHash)
|
||||
return emailHash.Email, err
|
||||
return emailHash.Email, nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -323,13 +323,7 @@ type RenamedBranch struct {
|
||||
|
||||
// FindRenamedBranch check if a branch was renamed
|
||||
func FindRenamedBranch(ctx context.Context, repoID int64, from string) (branch *RenamedBranch, exist bool, err error) {
|
||||
branch = &RenamedBranch{
|
||||
RepoID: repoID,
|
||||
From: from,
|
||||
}
|
||||
exist, err = db.GetEngine(ctx).Get(branch)
|
||||
|
||||
return branch, exist, err
|
||||
return db.Get[RenamedBranch](ctx, builder.Eq{"repo_id": repoID, "`from`": from})
|
||||
}
|
||||
|
||||
// RenameBranch rename a branch
|
||||
|
||||
+1
-2
@@ -126,8 +126,7 @@ func GetLFSMetaObjectByOid(ctx context.Context, repoID int64, oid string) (*LFSM
|
||||
return nil, ErrLFSObjectNotExist
|
||||
}
|
||||
|
||||
m := &LFSMetaObject{Pointer: lfs.Pointer{Oid: oid}, RepositoryID: repoID}
|
||||
has, err := db.GetEngine(ctx).Get(m)
|
||||
m, has, err := db.Get[LFSMetaObject](ctx, builder.Eq{"repository_id": repoID, "oid": oid})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ProtectedTag struct
|
||||
@@ -111,8 +113,7 @@ func GetProtectedTagByID(ctx context.Context, id int64) (*ProtectedTag, error) {
|
||||
|
||||
// GetProtectedTagByNamePattern gets protected tag by name_pattern
|
||||
func GetProtectedTagByNamePattern(ctx context.Context, repoID int64, pattern string) (*ProtectedTag, error) {
|
||||
tag := &ProtectedTag{NamePattern: pattern, RepoID: repoID}
|
||||
has, err := db.GetEngine(ctx).Get(tag)
|
||||
tag, has, err := db.Get[ProtectedTag](ctx, builder.Eq{"name_pattern": pattern, "repo_id": repoID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+25
-30
@@ -27,6 +27,7 @@ import (
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/references"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/structs"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/translation"
|
||||
@@ -626,11 +627,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
|
||||
return nil
|
||||
}
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
issue, err := GetIssueByID(ctx, c.IssueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
|
||||
}
|
||||
for i := range attachments {
|
||||
if err := validateAttachmentForIssue(ctx, issue, attachments[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
attachments[i].IssueID = c.IssueID
|
||||
attachments[i].CommentID = c.ID
|
||||
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
|
||||
@@ -643,36 +651,18 @@ func UpdateCommentAttachments(ctx context.Context, c *Comment, uuids []string) e
|
||||
}
|
||||
|
||||
// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees
|
||||
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) (err error) {
|
||||
if c.AssigneeID > 0 && c.Assignee == nil {
|
||||
c.Assignee, err = user_model.GetUserByID(ctx, c.AssigneeID)
|
||||
_, c.Assignee, err = user_model.GetPossibleUserByID(ctx, c.AssigneeID)
|
||||
if err != nil {
|
||||
if !user_model.IsErrUserNotExist(err) {
|
||||
return err
|
||||
}
|
||||
c.Assignee = user_model.NewGhostUser()
|
||||
}
|
||||
} else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
|
||||
if err = c.LoadIssue(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.LoadRepo(ctx); err != nil {
|
||||
}
|
||||
if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
|
||||
_, c.AssigneeTeam, err = organization.GetPossibleTeamByID(ctx, c.AssigneeTeamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = c.Issue.Repo.LoadOwner(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Issue.Repo.Owner.IsOrganization() {
|
||||
c.AssigneeTeam, err = organization.GetTeamByID(ctx, c.AssigneeTeamID)
|
||||
if err != nil && !organization.IsErrTeamNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -795,8 +785,7 @@ func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML {
|
||||
}
|
||||
|
||||
func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML {
|
||||
if c.AssigneeID > 0 {
|
||||
// it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist
|
||||
if c.Assignee != nil {
|
||||
if c.RemovedAssignee {
|
||||
if c.PosterID == c.AssigneeID {
|
||||
return locale.Tr("repo.issues.review.remove_review_request_self", createdStr)
|
||||
@@ -805,14 +794,20 @@ func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdSt
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr)
|
||||
}
|
||||
teamName := "Ghost Team"
|
||||
if c.AssigneeTeam != nil {
|
||||
teamName = c.AssigneeTeam.Name
|
||||
if c.RemovedAssignee {
|
||||
return locale.Tr("repo.issues.review.remove_review_request", c.AssigneeTeam.Name, createdStr)
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", c.AssigneeTeam.Name, createdStr)
|
||||
}
|
||||
|
||||
// impossible fallback
|
||||
assigneePrompt := fmt.Sprintf("(AssigneeID=%d, AssigneeTeamID=%d)", c.AssigneeID, c.AssigneeTeam.ID)
|
||||
setting.PanicInDevOrTesting("unknown timeline pull request review event comment: id=%d, %s", c.ID, assigneePrompt)
|
||||
if c.RemovedAssignee {
|
||||
return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr)
|
||||
return locale.Tr("repo.issues.review.remove_review_request", assigneePrompt, createdStr)
|
||||
}
|
||||
return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr)
|
||||
return locale.Tr("repo.issues.review.add_review_request", assigneePrompt, createdStr)
|
||||
}
|
||||
|
||||
// CreateComment creates comment with context
|
||||
|
||||
@@ -79,6 +79,14 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := comments.loadResolveDoers(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := comments.loadReactions(ctx, issue.Repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find all reviews by ReviewID
|
||||
reviews := make(map[int64]*Review)
|
||||
ids := make([]int64, 0, len(comments))
|
||||
@@ -107,14 +115,6 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu
|
||||
comments[n] = comment
|
||||
n++
|
||||
|
||||
if err := comment.LoadResolveDoer(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := comment.LoadReactions(ctx, issue.Repo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var err error
|
||||
rctx := renderhelper.NewRenderContextRepoComment(ctx, issue.Repo, renderhelper.RepoCommentOptions{
|
||||
FootnoteContextID: strconv.FormatInt(comment.ID, 10),
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// CommentList defines a list of comments
|
||||
@@ -444,6 +445,73 @@ func (comments CommentList) loadReviews(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadResolveDoers bulk-loads the resolve doer for all code comments that have one.
|
||||
func (comments CommentList) loadResolveDoers(ctx context.Context) error {
|
||||
resolveDoerIDs := container.FilterSlice(comments, func(c *Comment) (int64, bool) {
|
||||
return c.ResolveDoerID, c.ResolveDoerID != 0 && c.Type == CommentTypeCode
|
||||
})
|
||||
if len(resolveDoerIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
userMaps, err := user_model.GetUsersMapByIDs(ctx, resolveDoerIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if comment.ResolveDoerID == 0 || comment.Type != CommentTypeCode {
|
||||
continue
|
||||
}
|
||||
if u, ok := userMaps[comment.ResolveDoerID]; ok {
|
||||
comment.ResolveDoer = u
|
||||
} else {
|
||||
comment.ResolveDoer = user_model.NewGhostUser()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadReactions bulk-loads reactions for all comments in the list.
|
||||
func (comments CommentList) loadReactions(ctx context.Context, repo *repo_model.Repository) error {
|
||||
if len(comments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
commentIDs := container.FilterSlice(comments, func(c *Comment) (int64, bool) {
|
||||
return c.ID, c.Reactions == nil
|
||||
})
|
||||
if len(commentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var allReactions ReactionList
|
||||
if err := db.GetEngine(ctx).
|
||||
Where("`comment_id` > 0").
|
||||
In("comment_id", commentIDs).
|
||||
In("`type`", setting.UI.Reactions).
|
||||
Asc("issue_id", "comment_id", "created_unix", "id").
|
||||
Find(&allReactions); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := allReactions.LoadUsers(ctx, repo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reactByComment := make(map[int64]ReactionList, len(commentIDs))
|
||||
for _, r := range allReactions {
|
||||
reactByComment[r.CommentID] = append(reactByComment[r.CommentID], r)
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if comment.Reactions == nil {
|
||||
comment.Reactions = reactByComment[comment.ID]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAttributes loads attributes of the comments, except for attachments and
|
||||
// comments
|
||||
func (comments CommentList) LoadAttributes(ctx context.Context) (err error) {
|
||||
|
||||
@@ -45,12 +45,27 @@ func TestCreateComment(t *testing.T) {
|
||||
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
|
||||
}
|
||||
|
||||
func TestLoadAssigneeUserAndTeam_DeletedTeamBecomesGhostTeam(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 15})
|
||||
comment := &issues_model.Comment{
|
||||
Type: issues_model.CommentTypeAssignees,
|
||||
IssueID: issue.ID,
|
||||
AssigneeTeamID: 999999, // non-existing team ID
|
||||
}
|
||||
assert.NoError(t, comment.LoadAssigneeUserAndTeam(t.Context()))
|
||||
assert.NotNil(t, comment.AssigneeTeam)
|
||||
assert.EqualValues(t, -1, comment.AssigneeTeam.ID)
|
||||
}
|
||||
|
||||
func Test_UpdateCommentAttachment(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
|
||||
attachment := repo_model.Attachment{
|
||||
Name: "test.txt",
|
||||
RepoID: issue.RepoID, // must match the comment's repo, else the cross-repo guard rejects it
|
||||
Name: "test.txt",
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), &attachment))
|
||||
|
||||
|
||||
@@ -361,8 +361,8 @@ func (issue *Issue) ResetAttributesLoaded() {
|
||||
|
||||
// GetIsRead load the `IsRead` field of the issue
|
||||
func (issue *Issue) GetIsRead(ctx context.Context, userID int64) error {
|
||||
issueUser := &IssueUser{IssueID: issue.ID, UID: userID}
|
||||
if has, err := db.GetEngine(ctx).Get(issueUser); err != nil {
|
||||
issueUser, has, err := db.Get[IssueUser](ctx, builder.Eq{"issue_id": issue.ID, "uid": userID})
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
issue.IsRead = false
|
||||
@@ -499,11 +499,7 @@ func GetIssueByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
|
||||
if index < 1 {
|
||||
return nil, ErrIssueNotExist{}
|
||||
}
|
||||
issue := &Issue{
|
||||
RepoID: repoID,
|
||||
Index: index,
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(issue)
|
||||
issue, has, err := db.Get[Issue](ctx, builder.Eq{"repo_id": repoID, "`index`": index})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
|
||||
@@ -591,9 +591,12 @@ func (issues IssueList) GetApprovalCounts(ctx context.Context) (map[int64][]*Rev
|
||||
|
||||
func (issues IssueList) LoadIsRead(ctx context.Context, userID int64) error {
|
||||
issueIDs := issues.getIssueIDs()
|
||||
if len(issueIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
issueUsers := make([]*IssueUser, 0, len(issueIDs))
|
||||
if err := db.GetEngine(ctx).Where("uid =?", userID).
|
||||
In("issue_id").
|
||||
In("issue_id", issueIDs).
|
||||
Find(&issueUsers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIssueList_LoadRepositories(t *testing.T) {
|
||||
@@ -30,6 +31,22 @@ func TestIssueList_LoadRepositories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueList_LoadIsRead(t *testing.T) {
|
||||
// Regression: In("issue_id") was missing the issueIDs argument, causing
|
||||
// xorm to generate "0=1" and never mark any issue as read.
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
||||
|
||||
// Fixture: uid=1 has is_read=true on issue 1 only.
|
||||
issueList := issues_model.IssueList{issue1, issue2}
|
||||
require.NoError(t, issueList.LoadIsRead(t.Context(), 1))
|
||||
|
||||
assert.True(t, issue1.IsRead, "issue 1 should be marked read for user 1")
|
||||
assert.False(t, issue2.IsRead, "issue 2 should not be marked read for user 1")
|
||||
}
|
||||
|
||||
func TestIssueList_LoadAttributes(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
setting.Service.EnableTimetracking = true
|
||||
|
||||
@@ -263,14 +263,46 @@ func AddDeletePRBranchComment(ctx context.Context, doer *user_model.User, repo *
|
||||
return err
|
||||
}
|
||||
|
||||
// validateAttachmentForIssue rejects a foreign or already-linked attachment before it is linked to
|
||||
// issue: a known UUID could otherwise re-link (and expose) another repo's private attachment. A
|
||||
// legacy attachment predating repo_id-on-upload is adopted into the issue's repo.
|
||||
func validateAttachmentForIssue(ctx context.Context, issue *Issue, attachment *repo_model.Attachment) error {
|
||||
if attachment.RepoID == 0 && attachment.CreatedUnix < repo_model.LegacyAttachmentMissingRepoIDCutoff {
|
||||
attachment.RepoID = issue.RepoID
|
||||
if err := repo_model.UpdateAttachmentByUUID(ctx, attachment, "repo_id"); err != nil {
|
||||
return fmt.Errorf("update attachment repo_id [id: %d]: %w", attachment.ID, err)
|
||||
}
|
||||
}
|
||||
if attachment.RepoID != issue.RepoID {
|
||||
return util.NewPermissionDeniedErrorf("attachment belongs to a different repository")
|
||||
}
|
||||
if attachment.IssueID != 0 && attachment.IssueID != issue.ID {
|
||||
return util.NewPermissionDeniedErrorf("attachment is already linked to another issue")
|
||||
}
|
||||
if attachment.ReleaseID != 0 {
|
||||
return util.NewPermissionDeniedErrorf("attachment is already linked to a release")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateIssueAttachments update attachments by UUIDs for the issue
|
||||
func UpdateIssueAttachments(ctx context.Context, issueID int64, uuids []string) (err error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
issue, err := GetIssueByID(ctx, issueID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
|
||||
}
|
||||
for i := range attachments {
|
||||
if err := validateAttachmentForIssue(ctx, issue, attachments[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
attachments[i].IssueID = issueID
|
||||
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
|
||||
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
|
||||
@@ -599,7 +631,7 @@ func ResolveIssueMentionsByVisibility(ctx context.Context, issue *Issue, doer *u
|
||||
resolved[issue.Repo.Owner.LowerName+"/"+team.LowerName] = true
|
||||
continue
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(&organization.TeamUnit{OrgID: issue.Repo.Owner.ID, TeamID: team.ID, Type: unittype})
|
||||
has, err := db.Exist[organization.TeamUnit](ctx, builder.Eq{"org_id": issue.Repo.Owner.ID, "team_id": team.ID, "`type`": unittype})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get team units (%d): %w", team.ID, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issues_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateIssueAttachmentsCrossRepo(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// attachment id 2 belongs to repo 2 / issue 4; issue 1 lives in repo 1
|
||||
issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
||||
foreign := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 2})
|
||||
require.NotEqual(t, issue1.RepoID, foreign.RepoID)
|
||||
|
||||
// re-linking a foreign repo's attachment by UUID must be rejected
|
||||
err := issues_model.UpdateIssueAttachments(t.Context(), issue1.ID, []string{foreign.UUID})
|
||||
assert.ErrorIs(t, err, util.ErrPermissionDenied)
|
||||
|
||||
// the foreign attachment must be left untouched
|
||||
reloaded := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 2})
|
||||
assert.Equal(t, foreign.IssueID, reloaded.IssueID)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/references"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
type crossReference struct {
|
||||
@@ -189,11 +191,11 @@ func (issue *Issue) updateCrossReferenceList(list []*crossReference, xref *cross
|
||||
func (issue *Issue) verifyReferencedIssue(stdCtx context.Context, ctx *crossReferencesContext, repo *repo_model.Repository,
|
||||
ref references.IssueReference,
|
||||
) (*Issue, references.XRefAction, error) {
|
||||
refIssue := &Issue{RepoID: repo.ID, Index: ref.Index}
|
||||
refAction := ref.Action
|
||||
e := db.GetEngine(stdCtx)
|
||||
|
||||
if has, _ := e.Get(refIssue); !has {
|
||||
refIssue, has, err := db.Get[Issue](stdCtx, builder.Eq{"repo_id": repo.ID, "`index`": ref.Index})
|
||||
if err != nil {
|
||||
return nil, references.XRefActionNone, err
|
||||
} else if !has {
|
||||
return nil, references.XRefActionNone, nil
|
||||
}
|
||||
if err := refIssue.LoadRepo(stdCtx); err != nil {
|
||||
|
||||
+13
-6
@@ -6,6 +6,7 @@ package issues
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -27,12 +28,6 @@ type ErrRepoLabelNotExist struct {
|
||||
RepoID int64
|
||||
}
|
||||
|
||||
// IsErrRepoLabelNotExist checks if an error is a RepoErrLabelNotExist.
|
||||
func IsErrRepoLabelNotExist(err error) bool {
|
||||
_, ok := err.(ErrRepoLabelNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrRepoLabelNotExist) Error() string {
|
||||
return fmt.Sprintf("label does not exist [label_id: %d, repo_id: %d]", err.LabelID, err.RepoID)
|
||||
}
|
||||
@@ -312,6 +307,18 @@ func GetLabelInRepoByName(ctx context.Context, repoID int64, labelName string) (
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// GetLabelInRepoOrOrgByID returns the label with labelID scoped to the repo, falling back to the
|
||||
// repo's owning organization when ownerIsOrg is set. It returns ErrRepoLabelNotExist /
|
||||
// ErrOrgLabelNotExist when the label is in neither scope, so a foreign-but-existing label ID is
|
||||
// indistinguishable from a nonexistent one (no cross-repo enumeration oracle).
|
||||
func GetLabelInRepoOrOrgByID(ctx context.Context, repoID, ownerID int64, ownerIsOrg bool, labelID int64) (*Label, error) {
|
||||
label, err := GetLabelInRepoByID(ctx, repoID, labelID)
|
||||
if err != nil && errors.Is(err, util.ErrNotExist) && ownerIsOrg {
|
||||
return GetLabelInOrgByID(ctx, ownerID, labelID)
|
||||
}
|
||||
return label, err
|
||||
}
|
||||
|
||||
// GetLabelInRepoByID returns a label by ID in given repository.
|
||||
func GetLabelInRepoByID(ctx context.Context, repoID, labelID int64) (*Label, error) {
|
||||
if labelID <= 0 || repoID <= 0 {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -94,10 +95,10 @@ func TestGetLabelInRepoByName(t *testing.T) {
|
||||
assert.Equal(t, "label1", label.Name)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByName(t.Context(), 1, "")
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByName(t.Context(), unittest.NonexistentID, "nonexistent")
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestGetLabelInRepoByNames(t *testing.T) {
|
||||
@@ -131,10 +132,10 @@ func TestGetLabelInRepoByID(t *testing.T) {
|
||||
assert.EqualValues(t, 1, label.ID)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByID(t.Context(), 1, -1)
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
|
||||
_, err = issues_model.GetLabelInRepoByID(t.Context(), unittest.NonexistentID, unittest.NonexistentID)
|
||||
assert.True(t, issues_model.IsErrRepoLabelNotExist(err))
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestGetLabelsInRepoByIDs(t *testing.T) {
|
||||
|
||||
@@ -535,12 +535,8 @@ func GetPullRequestByIndex(ctx context.Context, repoID, index int64) (*PullReque
|
||||
if index < 1 {
|
||||
return nil, ErrPullRequestNotExist{}
|
||||
}
|
||||
pr := &PullRequest{
|
||||
BaseRepoID: repoID,
|
||||
Index: index,
|
||||
}
|
||||
|
||||
has, err := db.GetEngine(ctx).Get(pr)
|
||||
pr, has, err := db.Get[PullRequest](ctx, builder.Eq{"base_repo_id": repoID, "`index`": index})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
|
||||
@@ -324,6 +324,59 @@ func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organizatio
|
||||
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
|
||||
}
|
||||
|
||||
// RecalculateReviewsOfficial re-evaluates the "official" flag of the latest approve
|
||||
// and reject reviews of an issue against its pull request's current base branch.
|
||||
// It must be called whenever the target branch changes, otherwise an approval that
|
||||
// was official on the previous (possibly unprotected) branch would keep satisfying
|
||||
// the new branch's protection rules.
|
||||
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clearing and restoring the official flags must happen atomically, otherwise a
|
||||
// failure in between would leave the reviews without any official flag set.
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// Only the latest approve/reject review of each reviewer counts as official, so
|
||||
// clear the flag on all of them first and restore it only where it still applies.
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("issue_id = ?", issue.ID).
|
||||
In("type", ReviewTypeApprove, ReviewTypeReject).
|
||||
Cols("official").
|
||||
Update(&Review{Official: false}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reviews, err := FindLatestReviews(ctx, FindReviewOptions{
|
||||
Types: []ReviewType{ReviewTypeApprove, ReviewTypeReject},
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, review := range reviews {
|
||||
if err := review.LoadReviewer(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if review.Reviewer == nil {
|
||||
continue
|
||||
}
|
||||
official, err := IsOfficialReviewer(ctx, issue, review.Reviewer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if official {
|
||||
if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(&Review{Official: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateReview creates a new review based on opts
|
||||
func CreateReview(ctx context.Context, opts CreateReviewOptions) (*Review, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Review, error) {
|
||||
|
||||
@@ -6,6 +6,8 @@ package issues_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
@@ -386,3 +388,45 @@ func TestAddReviewRequest(t *testing.T) {
|
||||
assert.NotNil(t, comment.CommentMetaData)
|
||||
assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName)
|
||||
}
|
||||
|
||||
func TestRecalculateReviewsOfficial(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// PR #2 targets repo1's "master" branch. Simulate an approval that became
|
||||
// official while the PR targeted an unprotected branch.
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
review, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{
|
||||
Type: issues_model.ReviewTypeApprove,
|
||||
Issue: issue,
|
||||
Reviewer: reviewer,
|
||||
Official: true,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Protect the (now current) target branch with an approvals whitelist that
|
||||
// does not include the reviewer, mirroring a retarget onto a protected branch.
|
||||
rule := &git_model.ProtectedBranch{
|
||||
RepoID: issue.RepoID,
|
||||
RuleName: "master",
|
||||
EnableApprovalsWhitelist: true,
|
||||
ApprovalsWhitelistUserIDs: []int64{2},
|
||||
RequiredApprovals: 1,
|
||||
}
|
||||
assert.NoError(t, db.Insert(t.Context(), rule))
|
||||
|
||||
// Re-evaluating must strip the stale official flag, otherwise the approval
|
||||
// would still satisfy the protected branch's required approvals.
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.False(t, review.Official)
|
||||
|
||||
// Once the reviewer is whitelisted, re-evaluating restores the official flag.
|
||||
rule.ApprovalsWhitelistUserIDs = []int64{2, reviewer.ID}
|
||||
_, err = db.GetEngine(t.Context()).ID(rule.ID).Cols("approvals_whitelist_user_i_ds").Update(rule)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.True(t, review.Official)
|
||||
}
|
||||
|
||||
@@ -417,6 +417,9 @@ func prepareMigrationTasks() []*migration {
|
||||
newMigration(337, "Add visibility to team", v1_27.AddVisibilityToTeam),
|
||||
newMigration(338, "Expand legacy MSSQL issue/comment long-text columns", v1_27.ExpandIssueAndCommentLongTextFieldsForMSSQL),
|
||||
newMigration(339, "Extend action c_u index to include created_unix for faster dashboard feed queries", v1_27.AddCreatedUnixToActionUserIsDeletedIndex),
|
||||
newMigration(340, "Add ContinueOnError column to ActionRunJob", v1_27.AddContinueOnErrorToActionRunJob),
|
||||
newMigration(341, "Convert legacy MSSQL DATETIME columns to DATETIME2", v1_27.FixLegacyMSSQLDateTimeColumns),
|
||||
newMigration(342, "Add scoped workflows schema", v1_27.AddScopedWorkflowsSchema),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"gitea.dev/models/db"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AddContinueOnErrorToActionRunJob adds the ContinueOnError column to ActionRunJob,
|
||||
// storing the job-level continue-on-error value from the workflow YAML.
|
||||
func AddContinueOnErrorToActionRunJob(x db.EngineMigration) error {
|
||||
type ActionRunJob struct {
|
||||
ContinueOnError bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
}
|
||||
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreDropIndices: true,
|
||||
IgnoreConstrains: true,
|
||||
}, new(ActionRunJob))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/migrations/base"
|
||||
|
||||
"xorm.io/xorm/schemas"
|
||||
)
|
||||
|
||||
// legacyDateTimeColumns are the persisted real datetime columns that old Gitea
|
||||
// versions created as MSSQL DATETIME. Every other time value is stored as a
|
||||
// unix timestamp integer, so these are the only columns affected.
|
||||
var legacyDateTimeColumns = []struct {
|
||||
bean any
|
||||
column string
|
||||
}{
|
||||
{new(externalLoginUserWithExpiresAt), "expires_at"},
|
||||
{new(lfsLockWithCreated), "created"},
|
||||
}
|
||||
|
||||
type externalLoginUserWithExpiresAt struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (externalLoginUserWithExpiresAt) TableName() string {
|
||||
return "external_login_user"
|
||||
}
|
||||
|
||||
type lfsLockWithCreated struct {
|
||||
Created time.Time `xorm:"created"`
|
||||
}
|
||||
|
||||
func (lfsLockWithCreated) TableName() string {
|
||||
return "lfs_lock"
|
||||
}
|
||||
|
||||
// FixLegacyMSSQLDateTimeColumns converts legacy locale-dependent DATETIME columns
|
||||
// to DATETIME2. Databases created by old Gitea versions stored these columns as
|
||||
// DATETIME, which fails to parse ISO datetime strings ('YYYY-MM-DD HH:MM:SS')
|
||||
// when the MSSQL session language is not English, breaking external account
|
||||
// linking and LFS lock creation. New installs already use DATETIME2, so only
|
||||
// legacy MSSQL columns need converting.
|
||||
func FixLegacyMSSQLDateTimeColumns(x db.EngineMigration) error {
|
||||
if x.Dialect().URI().DBType != schemas.MSSQL {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, c := range legacyDateTimeColumns {
|
||||
table, err := x.TableInfo(c.bean)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var dataType string
|
||||
has, err := x.SQL("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ?", table.Name, c.column).Get(&dataType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !has || !strings.EqualFold(dataType, "datetime") {
|
||||
continue
|
||||
}
|
||||
|
||||
column := table.GetColumn(c.column)
|
||||
if column == nil {
|
||||
return fmt.Errorf("column %s does not exist in table %s", c.column, table.Name)
|
||||
}
|
||||
if err := base.ModifyColumn(x, table.Name, column); err != nil {
|
||||
return fmt.Errorf("modify %s.%s: %w", table.Name, c.column, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/migrations/migrationtest"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type externalLoginUserBeforeDateTimeMigration struct {
|
||||
ExternalID string `xorm:"pk NOT NULL"`
|
||||
LoginSourceID int64 `xorm:"pk NOT NULL"`
|
||||
ExpiresAt time.Time // sync creates DATETIME2; downgraded to legacy DATETIME via raw SQL below
|
||||
}
|
||||
|
||||
func (externalLoginUserBeforeDateTimeMigration) TableName() string {
|
||||
return "external_login_user"
|
||||
}
|
||||
|
||||
type lfsLockBeforeDateTimeMigration struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
Created time.Time `xorm:"created"`
|
||||
}
|
||||
|
||||
func (lfsLockBeforeDateTimeMigration) TableName() string {
|
||||
return "lfs_lock"
|
||||
}
|
||||
|
||||
func Test_FixLegacyMSSQLDateTimeColumns(t *testing.T) {
|
||||
if !setting.Database.Type.IsMSSQL() {
|
||||
t.Skip("Only MSSQL needs to convert the legacy locale-dependent DATETIME columns")
|
||||
}
|
||||
|
||||
x, deferrable := migrationtest.PrepareTestEnv(t, 0,
|
||||
new(externalLoginUserBeforeDateTimeMigration),
|
||||
new(lfsLockBeforeDateTimeMigration),
|
||||
)
|
||||
defer deferrable()
|
||||
|
||||
// Force the legacy DATETIME column type that old Gitea versions created.
|
||||
_, err := x.Exec("ALTER TABLE [external_login_user] ALTER COLUMN [expires_at] DATETIME")
|
||||
require.NoError(t, err)
|
||||
_, err = x.Exec("ALTER TABLE [lfs_lock] ALTER COLUMN [created] DATETIME")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "datetime", mssqlColumnType(t, x, "external_login_user", "expires_at"))
|
||||
require.Equal(t, "datetime", mssqlColumnType(t, x, "lfs_lock", "created"))
|
||||
|
||||
require.NoError(t, FixLegacyMSSQLDateTimeColumns(x))
|
||||
require.NoError(t, FixLegacyMSSQLDateTimeColumns(x)) // idempotent
|
||||
|
||||
require.Equal(t, "datetime2", mssqlColumnType(t, x, "external_login_user", "expires_at"))
|
||||
require.Equal(t, "datetime2", mssqlColumnType(t, x, "lfs_lock", "created"))
|
||||
|
||||
// Inserting an ISO-formatted datetime must succeed even under a non-English
|
||||
// locale, which is the failure the legacy DATETIME columns produced. The
|
||||
// SET LANGUAGE and INSERT run in one Exec so they share a single connection.
|
||||
_, err = x.Exec("SET LANGUAGE German; " +
|
||||
"INSERT INTO [external_login_user] ([external_id], [login_source_id], [expires_at]) " +
|
||||
"VALUES ('ext-id', 1, '2026-06-25 11:58:39')")
|
||||
require.NoError(t, err)
|
||||
_, err = x.Exec("SET LANGUAGE German; " +
|
||||
"INSERT INTO [lfs_lock] ([created]) VALUES ('2026-06-25 11:58:39')")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func mssqlColumnType(t *testing.T, x db.EngineMigration, table, column string) string {
|
||||
t.Helper()
|
||||
var dataType string
|
||||
has, err := x.SQL("SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ?", table, column).Get(&dataType)
|
||||
require.NoError(t, err)
|
||||
require.True(t, has)
|
||||
return dataType
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package v1_27
|
||||
|
||||
import (
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func AddScopedWorkflowsSchema(x db.EngineMigration) error {
|
||||
// Create the action_scoped_workflow_source table
|
||||
type ScopedWorkflowConfig struct {
|
||||
Required bool `json:"required"`
|
||||
Patterns []string `json:"patterns"`
|
||||
}
|
||||
type ActionScopedWorkflowSource struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OwnerID int64 `xorm:"UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
|
||||
SourceRepoID int64 `xorm:"INDEX UNIQUE(owner_repo) NOT NULL DEFAULT 0"`
|
||||
WorkflowConfigs map[string]*ScopedWorkflowConfig `xorm:"JSON TEXT 'workflow_configs'"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
if err := x.Sync(new(ActionScopedWorkflowSource)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add the columns that record where a run's workflow content came from
|
||||
type ActionRun struct {
|
||||
WorkflowRepoID int64 `xorm:"NOT NULL DEFAULT 0"`
|
||||
WorkflowCommitSHA string `xorm:"VARCHAR(64) NOT NULL DEFAULT ''"`
|
||||
IsScopedRun bool `xorm:"NOT NULL DEFAULT false"`
|
||||
}
|
||||
_, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||
IgnoreDropIndices: true,
|
||||
IgnoreConstrains: true,
|
||||
}, new(ActionRun))
|
||||
return err
|
||||
}
|
||||
@@ -411,11 +411,8 @@ func GetOrgByName(ctx context.Context, name string) (*Organization, error) {
|
||||
if len(name) == 0 {
|
||||
return nil, ErrOrgNotExist{0, name}
|
||||
}
|
||||
u := &Organization{
|
||||
LowerName: strings.ToLower(name),
|
||||
Type: user_model.UserTypeOrganization,
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(u)
|
||||
|
||||
u, has, err := db.Get[Organization](ctx, builder.Eq{"lower_name": strings.ToLower(name), "`type`": user_model.UserTypeOrganization})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
|
||||
@@ -6,6 +6,7 @@ package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -92,6 +93,15 @@ func (t *Team) IsPublic() bool { return t.Visibility.IsPublic() }
|
||||
func (t *Team) IsLimited() bool { return t.Visibility.IsLimited() }
|
||||
func (t *Team) IsPrivate() bool { return t.Visibility.IsPrivate() }
|
||||
|
||||
const (
|
||||
ghostTeamID = -1
|
||||
ghostTeamName = "(deleted team)"
|
||||
)
|
||||
|
||||
func newGhostTeam() *Team {
|
||||
return &Team{ID: ghostTeamID, Name: ghostTeamName, LowerName: ghostTeamName}
|
||||
}
|
||||
|
||||
// CanNonMemberReadMeta reports whether a non-member, non-owner doer may read
|
||||
// the team's metadata, based on the team's visibility tier and the parent org's
|
||||
// visibility. Privileged callers (site admins, org owners, team members) are
|
||||
@@ -270,6 +280,17 @@ func GetTeamByID(ctx context.Context, teamID int64) (*Team, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func GetPossibleTeamByID(ctx context.Context, teamID int64) (int64, *Team, error) {
|
||||
t, err := GetTeamByID(ctx, teamID)
|
||||
if errors.Is(err, util.ErrNotExist) {
|
||||
t = newGhostTeam()
|
||||
return t.ID, t, nil
|
||||
} else if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return t.ID, t, nil
|
||||
}
|
||||
|
||||
// IncrTeamRepoNum increases the number of repos for the given team by 1
|
||||
func IncrTeamRepoNum(ctx context.Context, teamID int64) error {
|
||||
_, err := db.GetEngine(ctx).Incr("num_repos").ID(teamID).Update(new(Team))
|
||||
|
||||
@@ -58,7 +58,7 @@ func GetProperties(ctx context.Context, refType PropertyType, refID int64) ([]*P
|
||||
// GetPropertiesByName gets all properties with a specific name
|
||||
func GetPropertiesByName(ctx context.Context, refType PropertyType, refID int64, name string) ([]*PackageProperty, error) {
|
||||
pps := make([]*PackageProperty, 0, 10)
|
||||
return pps, db.GetEngine(ctx).Where("ref_type = ? AND ref_id = ? AND name = ?", refType, refID, name).OrderBy("id").Find(&pps)
|
||||
return pps, db.GetEngine(ctx).Where("ref_type = ? AND ref_id = ? AND `name` = ?", refType, refID, name).OrderBy("id").Find(&pps)
|
||||
}
|
||||
|
||||
// UpdateProperty updates a property
|
||||
@@ -68,13 +68,12 @@ func UpdateProperty(ctx context.Context, pp *PackageProperty) error {
|
||||
}
|
||||
|
||||
func InsertOrUpdateProperty(ctx context.Context, refType PropertyType, refID int64, name, value string) error {
|
||||
pp := PackageProperty{RefType: refType, RefID: refID, Name: name}
|
||||
ok, err := db.GetEngine(ctx).Get(&pp)
|
||||
pp, ok, err := db.Get[PackageProperty](ctx, builder.Eq{"ref_type": refType, "ref_id": refID, "`name`": name})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
_, err = db.GetEngine(ctx).Where("ref_type=? AND ref_id=? AND name=?", refType, refID, name).Cols("value").Update(&PackageProperty{Value: value})
|
||||
_, err = db.GetEngine(ctx).ID(pp.ID).Cols("value").Update(&PackageProperty{Value: value})
|
||||
return err
|
||||
}
|
||||
_, err = InsertProperty(ctx, refType, refID, name, value)
|
||||
|
||||
@@ -164,9 +164,7 @@ func DeleteVersionsByPackageID(ctx context.Context, packageID int64) error {
|
||||
|
||||
// HasVersionFileReferences checks if there are associated files
|
||||
func HasVersionFileReferences(ctx context.Context, versionID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).Get(&PackageFile{
|
||||
VersionID: versionID,
|
||||
})
|
||||
return db.Exist[PackageFile](ctx, builder.Eq{"version_id": versionID})
|
||||
}
|
||||
|
||||
// SearchValue describes a value to search
|
||||
|
||||
@@ -95,6 +95,40 @@ func TestGetActionsUserRepoPermission(t *testing.T) {
|
||||
assert.False(t, perm.CanRead(unit.TypeCode))
|
||||
})
|
||||
|
||||
t.Run("CollaborativeOwner_ForkPR_Denied", func(t *testing.T) {
|
||||
// Target repo15 trusts repo2's owner as a collaborative owner.
|
||||
repo15ActionsUnit := repo15.MustGetUnit(ctx, unit.TypeActions)
|
||||
repo15ActionsUnit.ActionsConfig().AddCollaborativeOwner(owner2.ID)
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo15ActionsUnit))
|
||||
|
||||
// Owner cross-repo policy does not allow repo15, so the only branch that
|
||||
// could grant access is the collaborative-owner one.
|
||||
require.NoError(t, actions_model.SetOwnerActionsConfig(ctx, owner2.ID, actions_model.OwnerActionsConfig{}))
|
||||
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
|
||||
// Non-fork task is legitimately granted code-read via collaborative owner.
|
||||
task53.IsForkPullRequest = false
|
||||
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
|
||||
perm, err := GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, perm.CanRead(unit.TypeCode))
|
||||
|
||||
// Fork PR must NOT be able to read a third private repo through the
|
||||
// collaborative-owner branch.
|
||||
task53.IsForkPullRequest = true
|
||||
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
|
||||
perm, err = GetActionsUserRepoPermission(ctx, repo15, actionsUser, task53.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, perm.CanRead(unit.TypeCode))
|
||||
|
||||
// Restore state for subsequent subtests.
|
||||
task53.IsForkPullRequest = false
|
||||
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
|
||||
repo15ActionsUnit.ActionsConfig().RemoveCollaborativeOwner(owner2.ID)
|
||||
require.NoError(t, repo_model.UpdateRepoUnitConfig(ctx, repo15ActionsUnit))
|
||||
})
|
||||
|
||||
t.Run("Inheritance_And_Clamping", func(t *testing.T) {
|
||||
task53 := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 53})
|
||||
task53.IsForkPullRequest = false
|
||||
|
||||
@@ -369,7 +369,9 @@ func GetActionsUserRepoPermission(ctx context.Context, repo *repo_model.Reposito
|
||||
// 2. The Actions Bot user has been explicitly granted access and repository is private
|
||||
// 3. The repository is public (handled by botPerm above)
|
||||
|
||||
if taskRepo.IsPrivate {
|
||||
// Fork PRs are never allowed cross-repo access to other private repositories,
|
||||
// matching the discriminator enforced by checkSameOwnerCrossRepoAccess above.
|
||||
if taskRepo.IsPrivate && !task.IsForkPullRequest {
|
||||
actionsUnit := repo.MustGetUnit(ctx, unit.TypeActions)
|
||||
if actionsUnit.ActionsConfig().IsCollaborativeOwner(taskRepo.OwnerID) {
|
||||
return maxPerm, nil
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ViewedState stores for a file in which state it is currently viewed
|
||||
@@ -66,9 +68,14 @@ func (rs *ReviewState) GetViewedFileCount() int {
|
||||
// If the review didn't exist before in the database, it won't afterwards either.
|
||||
// The returned boolean shows whether the review exists in the database
|
||||
func GetReviewState(ctx context.Context, userID, pullID int64, commitSHA string) (*ReviewState, bool, error) {
|
||||
review := &ReviewState{UserID: userID, PullID: pullID, CommitSHA: commitSHA}
|
||||
has, err := db.GetEngine(ctx).Get(review)
|
||||
return review, has, err
|
||||
review, has, err := db.Get[ReviewState](ctx, builder.Eq{"user_id": userID, "pull_id": pullID, "commit_sha": commitSHA})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if review == nil {
|
||||
review = &ReviewState{UserID: userID, PullID: pullID, CommitSHA: commitSHA}
|
||||
}
|
||||
return review, has, nil
|
||||
}
|
||||
|
||||
// UpdateReviewState updates the given review inside the database, regardless of whether it existed before or not
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// Attachment represent a attachment of issue/comment/release.
|
||||
@@ -156,8 +158,7 @@ func GetAttachmentsByCommentID(ctx context.Context, commentID int64) ([]*Attachm
|
||||
|
||||
// GetAttachmentByReleaseIDFileName returns attachment by given releaseId and fileName.
|
||||
func GetAttachmentByReleaseIDFileName(ctx context.Context, releaseID int64, fileName string) (*Attachment, error) {
|
||||
attach := &Attachment{ReleaseID: releaseID, Name: fileName}
|
||||
has, err := db.GetEngine(ctx).Get(attach)
|
||||
attach, has, err := db.Get[Attachment](ctx, builder.Eq{"release_id": releaseID, "`name`": fileName})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
|
||||
@@ -102,20 +102,13 @@ func GetCollaborators(ctx context.Context, opts *FindCollaborationOptions) ([]*C
|
||||
|
||||
// GetCollaboration get collaboration for a repository id with a user id
|
||||
func GetCollaboration(ctx context.Context, repoID, uid int64) (*Collaboration, error) {
|
||||
collaboration := &Collaboration{
|
||||
RepoID: repoID,
|
||||
UserID: uid,
|
||||
}
|
||||
has, err := db.GetEngine(ctx).Get(collaboration)
|
||||
if !has {
|
||||
collaboration = nil
|
||||
}
|
||||
collaboration, _, err := db.Get[Collaboration](ctx, builder.Eq{"repo_id": repoID, "user_id": uid})
|
||||
return collaboration, err
|
||||
}
|
||||
|
||||
// IsCollaborator check if a user is a collaborator of a repository
|
||||
func IsCollaborator(ctx context.Context, repoID, userID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).Get(&Collaboration{RepoID: repoID, UserID: userID})
|
||||
return db.Exist[Collaboration](ctx, builder.Eq{"repo_id": repoID, "user_id": userID})
|
||||
}
|
||||
|
||||
// ChangeCollaborationAccessMode sets new access mode for the collaboration.
|
||||
@@ -126,13 +119,7 @@ func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid in
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
collaboration := &Collaboration{
|
||||
RepoID: repo.ID,
|
||||
UserID: uid,
|
||||
}
|
||||
has, err := e.Get(collaboration)
|
||||
collaboration, has, err := db.Get[Collaboration](ctx, builder.Eq{"repo_id": repo.ID, "user_id": uid})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get collaboration: %w", err)
|
||||
} else if !has {
|
||||
@@ -144,12 +131,12 @@ func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid in
|
||||
}
|
||||
collaboration.Mode = mode
|
||||
|
||||
if _, err = e.
|
||||
if _, err = db.GetEngine(ctx).
|
||||
ID(collaboration.ID).
|
||||
Cols("mode").
|
||||
Update(collaboration); err != nil {
|
||||
return fmt.Errorf("update collaboration: %w", err)
|
||||
} else if _, err = e.Exec("UPDATE access SET mode = ? WHERE user_id = ? AND repo_id = ?", mode, uid, repo.ID); err != nil {
|
||||
} else if _, err = db.Exec(ctx, "UPDATE access SET mode = ? WHERE user_id = ? AND repo_id = ?", mode, uid, repo.ID); err != nil {
|
||||
return fmt.Errorf("update access table: %w", err)
|
||||
}
|
||||
|
||||
@@ -174,5 +161,5 @@ func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return db.GetEngine(ctx).Get(&Collaboration{RepoID: repo.ID, UserID: userID})
|
||||
return db.Exist[Collaboration](ctx, builder.Eq{"repo_id": repo.ID, "user_id": userID})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrMirrorNotExist mirror does not exist error
|
||||
@@ -76,8 +78,7 @@ func (m *Mirror) ScheduleNextUpdate() {
|
||||
|
||||
// GetMirrorByRepoID returns mirror information of a repository.
|
||||
func GetMirrorByRepoID(ctx context.Context, repoID int64) (*Mirror, error) {
|
||||
m := &Mirror{RepoID: repoID}
|
||||
has, err := db.GetEngine(ctx).Get(m)
|
||||
m, has, err := db.Get[Mirror](ctx, builder.Eq{"repo_id": repoID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
|
||||
+23
-8
@@ -31,18 +31,28 @@ func GetOrgRepositoryIDs(ctx context.Context, orgID int64) (repoIDs []int64, _ e
|
||||
type SearchTeamRepoOptions struct {
|
||||
db.ListOptions
|
||||
TeamID int64
|
||||
// PublicOnly restricts the result (and count) to non-private repositories.
|
||||
PublicOnly bool
|
||||
}
|
||||
|
||||
func (opts *SearchTeamRepoOptions) toCond() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.TeamID > 0 {
|
||||
cond = cond.And(builder.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
))
|
||||
}
|
||||
if opts.PublicOnly {
|
||||
cond = cond.And(builder.Eq{"is_private": false})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetTeamRepositories returns paginated repositories in team of organization.
|
||||
func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (RepositoryList, error) {
|
||||
sess := db.GetEngine(ctx)
|
||||
if opts.TeamID > 0 {
|
||||
sess = sess.In("id",
|
||||
builder.Select("repo_id").
|
||||
From("team_repo").
|
||||
Where(builder.Eq{"team_id": opts.TeamID}),
|
||||
)
|
||||
}
|
||||
sess := db.GetEngine(ctx).Where(opts.toCond())
|
||||
if opts.PageSize > 0 {
|
||||
sess.Limit(opts.PageSize, (opts.Page-1)*opts.PageSize)
|
||||
}
|
||||
@@ -51,6 +61,11 @@ func GetTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (Repo
|
||||
Find(&repos)
|
||||
}
|
||||
|
||||
// CountTeamRepositories returns the number of repositories in team of organization matching opts.
|
||||
func CountTeamRepositories(ctx context.Context, opts *SearchTeamRepoOptions) (int64, error) {
|
||||
return db.GetEngine(ctx).Where(opts.toCond()).Count(new(Repository))
|
||||
}
|
||||
|
||||
// AccessibleReposEnvironment operations involving the repositories that are
|
||||
// accessible to a particular user
|
||||
type AccessibleReposEnvironment interface {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrRedirectNotExist represents a "RedirectNotExist" kind of error.
|
||||
@@ -52,8 +54,8 @@ func init() {
|
||||
// LookupRedirect look up if a repository has a redirect name
|
||||
func LookupRedirect(ctx context.Context, ownerID int64, repoName string) (int64, error) {
|
||||
repoName = strings.ToLower(repoName)
|
||||
redirect := &Redirect{OwnerID: ownerID, LowerName: repoName}
|
||||
if has, err := db.GetEngine(ctx).Get(redirect); err != nil {
|
||||
redirect, has, err := db.Get[Redirect](ctx, builder.Eq{"owner_id": ownerID, "lower_name": repoName})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
} else if !has {
|
||||
return 0, ErrRedirectNotExist{OwnerID: ownerID, RepoName: repoName}
|
||||
|
||||
@@ -216,8 +216,7 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
|
||||
|
||||
// GetRelease returns release by given ID.
|
||||
func GetRelease(ctx context.Context, repoID int64, tagName string) (*Release, error) {
|
||||
rel := &Release{RepoID: repoID, LowerTagName: strings.ToLower(tagName)}
|
||||
has, err := db.GetEngine(ctx).Get(rel)
|
||||
rel, has, err := db.Get[Release](ctx, builder.Eq{"repo_id": repoID, "lower_tag_name": strings.ToLower(tagName)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
|
||||
+3
-3
@@ -849,9 +849,9 @@ func GetRepositoriesMapByIDs(ctx context.Context, ids []int64) (map[int64]*Repos
|
||||
}
|
||||
|
||||
func IsRepositoryModelExist(ctx context.Context, u *user_model.User, repoName string) (bool, error) {
|
||||
return db.GetEngine(ctx).Get(&Repository{
|
||||
OwnerID: u.ID,
|
||||
LowerName: strings.ToLower(repoName),
|
||||
return db.Exist[Repository](ctx, builder.Eq{
|
||||
"owner_id": u.ID,
|
||||
"lower_name": strings.ToLower(repoName),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -310,11 +310,17 @@ func userOrgTeamRepoBuilder(userID int64) *builder.Builder {
|
||||
}
|
||||
|
||||
// userOrgTeamUnitRepoBuilder returns repo ids where user's teams can access the special unit.
|
||||
// A team grants the unit either through an explicit team_unit row (access_mode > none) or by being an
|
||||
// admin/owner team (team.authorize >= admin), which grants every unit regardless of team_unit rows —
|
||||
// mirroring the HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
|
||||
func userOrgTeamUnitRepoBuilder(userID int64, unitType unit.Type) *builder.Builder {
|
||||
return userOrgTeamRepoBuilder(userID).
|
||||
Join("INNER", "team_unit", "`team_unit`.team_id = `team_repo`.team_id").
|
||||
Where(builder.Eq{"`team_unit`.`type`": unitType}).
|
||||
And(builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)})
|
||||
Join("INNER", "team", "`team`.id = `team_repo`.team_id").
|
||||
Join("LEFT", "team_unit", builder.Expr("`team_unit`.team_id = `team_repo`.team_id AND `team_unit`.`type` = ?", unitType)).
|
||||
Where(builder.Or(
|
||||
builder.Gte{"`team`.authorize": int(perm.AccessModeAdmin)},
|
||||
builder.Gt{"`team_unit`.`access_mode`": int(perm.AccessModeNone)},
|
||||
))
|
||||
}
|
||||
|
||||
// userOrgTeamUnitRepoCond returns a condition to select repo ids where user's teams can access the special unit.
|
||||
@@ -326,7 +332,7 @@ func userOrgTeamUnitRepoCond(idStr string, userID int64, unitType unit.Type) bui
|
||||
func UserOrgUnitRepoCond(idStr string, userID, orgID int64, unitType unit.Type) builder.Cond {
|
||||
return builder.In(idStr,
|
||||
userOrgTeamUnitRepoBuilder(userID, unitType).
|
||||
And(builder.Eq{"`team_unit`.org_id": orgID}),
|
||||
And(builder.Eq{"`team`.org_id": orgID}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -755,6 +761,40 @@ func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user
|
||||
))
|
||||
}
|
||||
|
||||
// PublicRepoUnderPublicOwnerCond restricts to public repos whose owner is publicly visible: the
|
||||
// "genuinely public" set a public-only token or an anonymous caller may see (a public repo under a
|
||||
// limited/private owner is not publicly reachable and must be excluded).
|
||||
func PublicRepoUnderPublicOwnerCond() builder.Cond {
|
||||
return builder.And(
|
||||
builder.Eq{"`repository`.is_private": false},
|
||||
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePublic})),
|
||||
)
|
||||
}
|
||||
|
||||
// UserActionsAccessibleOwnerRepoCond selects the repos owned by ownerID whose Actions `user` may read.
|
||||
// It is used to list an org/user's Actions runs and jobs (see the callers in routers/api/v1/shared).
|
||||
// - owner_id = ownerID: only that owner's repos.
|
||||
// - AccessibleRepositoryCondition(user, TypeActions): only repos whose Actions the user can read
|
||||
// (admin/owner teams are handled inside it; a site admin is not, callers must skip the filter for one).
|
||||
// - publicOnly (a public-only token): additionally limit to public repos under a public owner.
|
||||
func UserActionsAccessibleOwnerRepoCond(ownerID int64, user *user_model.User, publicOnly bool) builder.Cond {
|
||||
cond := builder.NewCond().And(
|
||||
builder.Eq{"`repository`.owner_id": ownerID},
|
||||
AccessibleRepositoryCondition(user, unit.TypeActions),
|
||||
)
|
||||
if publicOnly {
|
||||
cond = cond.And(PublicRepoUnderPublicOwnerCond())
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// FindUserActionsAccessibleOwnerRepoIDsSubQuery returns a subquery selecting the repository IDs the user
|
||||
// can see for the given owner. Callers embed it in an `IN (...)` condition so that a large owner does not
|
||||
// materialize every repo ID into the SQL statement, which could exceed database parameter limits.
|
||||
func FindUserActionsAccessibleOwnerRepoIDsSubQuery(ownerID int64, user *user_model.User, publicOnly bool) *builder.Builder {
|
||||
return builder.Select("id").From("repository").Where(UserActionsAccessibleOwnerRepoCond(ownerID, user, publicOnly))
|
||||
}
|
||||
|
||||
// GetUserRepositories returns a list of repositories of given user.
|
||||
func GetUserRepositories(ctx context.Context, opts SearchRepoOptions) (RepositoryList, int64, error) {
|
||||
if len(opts.OrderBy) == 0 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/optional"
|
||||
@@ -466,3 +467,50 @@ func TestSearchRepositoryByTopicName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserActionsAccessibleOwnerRepoIDs(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// user2 is on org3's owner team, so it can access org3's private repo3 (which has the actions unit)
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
// org3 is a public org owning repo3 (private) and repo32 (public), both with the actions unit
|
||||
const orgID = 3
|
||||
|
||||
all, err := repo_model.SearchRepositoryIDsByCondition(t.Context(), repo_model.UserActionsAccessibleOwnerRepoCond(orgID, user, false))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, all, int64(3), "without public-only the private repo's actions are listed")
|
||||
|
||||
publicOnly, err := repo_model.SearchRepositoryIDsByCondition(t.Context(), repo_model.UserActionsAccessibleOwnerRepoCond(orgID, user, true))
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, publicOnly, int64(3), "a public-only token must not list a private repo's actions")
|
||||
assert.Contains(t, publicOnly, int64(32), "a public repo under a public owner stays listed")
|
||||
}
|
||||
|
||||
// TestUserOrgUnitRepoCondTeamAuthorize pins the team.authorize behavior of userOrgTeamUnitRepoBuilder
|
||||
// (exercised through UserOrgUnitRepoCond): an admin/owner team grants every unit even without an explicit
|
||||
// team_unit row, while a non-admin team only grants a unit it has an explicit row for. This guards both
|
||||
// directions — hiding repos from admin-team members, and over-broadening a plain team's access.
|
||||
func TestUserOrgUnitRepoCondTeamAuthorize(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
accessibleRepoIDs := func(userID, orgID int64, unitType unit.Type) []int64 {
|
||||
ids, err := repo_model.SearchRepositoryIDsByCondition(t.Context(),
|
||||
repo_model.UserOrgUnitRepoCond("`repository`.id", userID, orgID, unitType))
|
||||
require.NoError(t, err)
|
||||
return ids
|
||||
}
|
||||
|
||||
// Case A: user18 is only on org17's owner team (team5, authorize=owner), linked to the private repo24
|
||||
// but with no Actions team_unit row. The owner authorize must still grant it, mirroring the runtime
|
||||
// HasAdminAccess() short-circuit in access.GetIndividualUserRepoPermission.
|
||||
assert.Contains(t, accessibleRepoIDs(18, 17, unit.TypeActions), int64(24),
|
||||
"an owner team grants a unit it has no explicit team_unit row for")
|
||||
|
||||
// Cases B and C share one subject so the team_unit row is the only difference: user4 is only on org3's
|
||||
// write team (team2, authorize=write, non-admin), linked to the private repo3. team2 has an explicit
|
||||
// Projects row but none for Actions.
|
||||
assert.Contains(t, accessibleRepoIDs(4, 3, unit.TypeProjects), int64(3),
|
||||
"a non-admin team grants a unit it has an explicit team_unit row for")
|
||||
assert.NotContains(t, accessibleRepoIDs(4, 3, unit.TypeActions), int64(3),
|
||||
"a non-admin team must NOT grant a unit it has no team_unit row for")
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ func MakeRestrictedPermissions() ActionsTokenPermissions {
|
||||
|
||||
type ActionsConfig struct {
|
||||
DisabledWorkflows []string
|
||||
// DisabledScopedWorkflows maps a scoped workflow's source repository ID to the entry names opted out of in this repository.
|
||||
DisabledScopedWorkflows map[int64][]string
|
||||
// CollaborativeOwnerIDs is a list of owner IDs used to share actions from private repos.
|
||||
// Only workflows from the private repos whose owners are in CollaborativeOwnerIDs can access the current repo's actions.
|
||||
CollaborativeOwnerIDs []int64
|
||||
@@ -98,6 +100,29 @@ func (cfg *ActionsConfig) DisableWorkflow(file string) {
|
||||
cfg.DisabledWorkflows = append(cfg.DisabledWorkflows, file)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) IsScopedWorkflowDisabled(sourceRepoID int64, workflowID string) bool {
|
||||
return slices.Contains(cfg.DisabledScopedWorkflows[sourceRepoID], workflowID)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) DisableScopedWorkflow(sourceRepoID int64, workflowID string) {
|
||||
if slices.Contains(cfg.DisabledScopedWorkflows[sourceRepoID], workflowID) {
|
||||
return
|
||||
}
|
||||
if cfg.DisabledScopedWorkflows == nil {
|
||||
cfg.DisabledScopedWorkflows = make(map[int64][]string)
|
||||
}
|
||||
cfg.DisabledScopedWorkflows[sourceRepoID] = append(cfg.DisabledScopedWorkflows[sourceRepoID], workflowID)
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) EnableScopedWorkflow(sourceRepoID int64, workflowID string) {
|
||||
workflowIDs := util.SliceRemoveAll(cfg.DisabledScopedWorkflows[sourceRepoID], workflowID)
|
||||
if len(workflowIDs) == 0 {
|
||||
delete(cfg.DisabledScopedWorkflows, sourceRepoID)
|
||||
return
|
||||
}
|
||||
cfg.DisabledScopedWorkflows[sourceRepoID] = workflowIDs
|
||||
}
|
||||
|
||||
func (cfg *ActionsConfig) AddCollaborativeOwner(ownerID int64) {
|
||||
if !slices.Contains(cfg.CollaborativeOwnerIDs, ownerID) {
|
||||
cfg.CollaborativeOwnerIDs = append(cfg.CollaborativeOwnerIDs, ownerID)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActionsConfig_ScopedWorkflowOptOut(t *testing.T) {
|
||||
cfg := &ActionsConfig{}
|
||||
|
||||
assert.False(t, cfg.IsScopedWorkflowDisabled(100, "ci.yml"))
|
||||
|
||||
cfg.DisableScopedWorkflow(100, "ci.yml")
|
||||
assert.True(t, cfg.IsScopedWorkflowDisabled(100, "ci.yml"))
|
||||
|
||||
// idempotent
|
||||
cfg.DisableScopedWorkflow(100, "ci.yml")
|
||||
assert.Len(t, cfg.DisabledScopedWorkflows, 1)
|
||||
|
||||
// keyed by source repo: the same filename from a different source repo is independent
|
||||
assert.False(t, cfg.IsScopedWorkflowDisabled(200, "ci.yml"))
|
||||
|
||||
// must not collide with the repo-level DisabledWorkflows list (bare filename)
|
||||
assert.False(t, cfg.IsWorkflowDisabled("ci.yml"))
|
||||
cfg.DisableWorkflow("ci.yml")
|
||||
assert.True(t, cfg.IsWorkflowDisabled("ci.yml"))
|
||||
assert.True(t, cfg.IsScopedWorkflowDisabled(100, "ci.yml"), "repo-level disable must not touch the scoped entry")
|
||||
|
||||
cfg.EnableScopedWorkflow(100, "ci.yml")
|
||||
assert.False(t, cfg.IsScopedWorkflowDisabled(100, "ci.yml"))
|
||||
assert.True(t, cfg.IsWorkflowDisabled("ci.yml"), "enabling the scoped entry must not touch the repo-level disable")
|
||||
}
|
||||
|
||||
func TestActionsConfig_ScopedWorkflowSerialization(t *testing.T) {
|
||||
cfg := &ActionsConfig{}
|
||||
cfg.DisableScopedWorkflow(100, "ci.yml")
|
||||
cfg.DisableWorkflow("repo.yml")
|
||||
|
||||
bs, err := cfg.ToDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
got := &ActionsConfig{}
|
||||
require.NoError(t, got.FromDB(bs))
|
||||
assert.True(t, got.IsScopedWorkflowDisabled(100, "ci.yml"))
|
||||
assert.True(t, got.IsWorkflowDisabled("repo.yml"))
|
||||
}
|
||||
+3
-1
@@ -9,6 +9,8 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// Star represents a starred repo by a user.
|
||||
@@ -68,7 +70,7 @@ func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star
|
||||
|
||||
// IsStaring checks if user has starred given repository.
|
||||
func IsStaring(ctx context.Context, userID, repoID int64) bool {
|
||||
has, _ := db.GetEngine(ctx).Get(&Star{UID: userID, RepoID: repoID})
|
||||
has, _ := db.Exist[Star](ctx, builder.Eq{"uid": userID, "repo_id": repoID})
|
||||
return has
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ type StarredReposOptions struct {
|
||||
StarrerID int64
|
||||
RepoOwnerID int64
|
||||
IncludePrivate bool
|
||||
// Actor is the user the private repositories are gated on: a private repo is only
|
||||
// returned when Actor still has access to it, even if it was starred while access was granted.
|
||||
Actor *user_model.User
|
||||
}
|
||||
|
||||
func (opts *StarredReposOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
@@ -39,10 +42,12 @@ func (opts *StarredReposOptions) ToConds() builder.Cond {
|
||||
"repository.owner_id": opts.RepoOwnerID,
|
||||
})
|
||||
}
|
||||
if !opts.IncludePrivate {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.is_private": false,
|
||||
})
|
||||
if opts.IncludePrivate {
|
||||
// only include private repos the actor can still access, so metadata does not leak after access revocation
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
} else {
|
||||
// a public repo under a limited/private owner is not publicly reachable, so exclude it too
|
||||
cond = cond.And(PublicRepoUnderPublicOwnerCond())
|
||||
}
|
||||
return cond
|
||||
}
|
||||
@@ -66,6 +71,9 @@ type WatchedReposOptions struct {
|
||||
WatcherID int64
|
||||
RepoOwnerID int64
|
||||
IncludePrivate bool
|
||||
// Actor is the user the private repositories are gated on: a private repo is only
|
||||
// returned when Actor still has access to it, even if it was watched while access was granted.
|
||||
Actor *user_model.User
|
||||
}
|
||||
|
||||
func (opts *WatchedReposOptions) ApplyPublicOnly(publicOnly bool) {
|
||||
@@ -83,10 +91,12 @@ func (opts *WatchedReposOptions) ToConds() builder.Cond {
|
||||
"repository.owner_id": opts.RepoOwnerID,
|
||||
})
|
||||
}
|
||||
if !opts.IncludePrivate {
|
||||
cond = cond.And(builder.Eq{
|
||||
"repository.is_private": false,
|
||||
})
|
||||
if opts.IncludePrivate {
|
||||
// only include private repos the actor can still access, so metadata does not leak after access revocation
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
} else {
|
||||
// a public repo under a limited/private owner is not publicly reachable, so exclude it too
|
||||
cond = cond.And(PublicRepoUnderPublicOwnerCond())
|
||||
}
|
||||
return cond.And(builder.Neq{
|
||||
"watch.mode": WatchModeDont,
|
||||
|
||||
@@ -84,3 +84,40 @@ func testUserRepoGetIssuePostersWithSearch(t *testing.T) {
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, "user2", users[0].Name)
|
||||
}
|
||||
|
||||
func TestStarredWatchedReposExcludeNonPublicOwners(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const viewerID = 2
|
||||
// repo1: public repo under a public owner; repo38: public repo under a limited org (not publicly reachable)
|
||||
const publicOwnerRepo, limitedOwnerRepo = 1, 38
|
||||
|
||||
require.NoError(t, db.Insert(t.Context(), &repo_model.Star{UID: viewerID, RepoID: publicOwnerRepo}))
|
||||
require.NoError(t, db.Insert(t.Context(), &repo_model.Star{UID: viewerID, RepoID: limitedOwnerRepo}))
|
||||
require.NoError(t, db.Insert(t.Context(), &repo_model.Watch{UserID: viewerID, RepoID: publicOwnerRepo, Mode: repo_model.WatchModeNormal}))
|
||||
require.NoError(t, db.Insert(t.Context(), &repo_model.Watch{UserID: viewerID, RepoID: limitedOwnerRepo, Mode: repo_model.WatchModeNormal}))
|
||||
|
||||
listOpts := db.ListOptions{Page: 1, PageSize: 50}
|
||||
|
||||
starred, err := repo_model.GetStarredRepos(t.Context(), &repo_model.StarredReposOptions{
|
||||
ListOptions: listOpts, StarrerID: viewerID, IncludePrivate: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, repoIDs(starred), int64(limitedOwnerRepo), "a public repo under a limited owner must be hidden from a public star listing")
|
||||
assert.Contains(t, repoIDs(starred), int64(publicOwnerRepo), "a public repo under a public owner stays visible")
|
||||
|
||||
watched, _, err := repo_model.GetWatchedRepos(t.Context(), &repo_model.WatchedReposOptions{
|
||||
ListOptions: listOpts, WatcherID: viewerID, IncludePrivate: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, repoIDs(watched), int64(limitedOwnerRepo), "a public repo under a limited owner must be hidden from a public watch listing")
|
||||
assert.Contains(t, repoIDs(watched), int64(publicOwnerRepo), "a public repo under a public owner stays visible")
|
||||
}
|
||||
|
||||
func repoIDs(repos []*repo_model.Repository) []int64 {
|
||||
ids := make([]int64, len(repos))
|
||||
for i, r := range repos {
|
||||
ids[i] = r.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// WatchMode specifies what kind of watch the user has on a repository
|
||||
@@ -41,12 +43,14 @@ func init() {
|
||||
}
|
||||
|
||||
// GetWatch gets what kind of subscription a user has on a given repository; returns dummy record if none found
|
||||
func GetWatch(ctx context.Context, userID, repoID int64) (Watch, error) {
|
||||
watch := Watch{UserID: userID, RepoID: repoID}
|
||||
has, err := db.GetEngine(ctx).Get(&watch)
|
||||
func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) {
|
||||
watch, has, err := db.Get[Watch](ctx, builder.Eq{"user_id": userID, "repo_id": repoID})
|
||||
if err != nil {
|
||||
return watch, err
|
||||
}
|
||||
if watch == nil {
|
||||
watch = &Watch{UserID: userID, RepoID: repoID}
|
||||
}
|
||||
if !has {
|
||||
watch.Mode = WatchModeNone
|
||||
}
|
||||
@@ -64,7 +68,7 @@ func IsWatching(ctx context.Context, userID, repoID int64) bool {
|
||||
return err == nil && IsWatchMode(watch.Mode)
|
||||
}
|
||||
|
||||
func watchRepoMode(ctx context.Context, watch Watch, mode WatchMode) (err error) {
|
||||
func watchRepoMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
|
||||
if watch.Mode == mode {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// AppState represents a state record in database
|
||||
@@ -44,9 +46,7 @@ func SaveAppStateContent(ctx context.Context, key, content string) error {
|
||||
|
||||
// GetAppStateContent gets an app state from database
|
||||
func GetAppStateContent(ctx context.Context, key string) (content string, err error) {
|
||||
e := db.GetEngine(ctx)
|
||||
appState := &AppState{ID: key}
|
||||
has, err := e.Get(appState)
|
||||
appState, has, err := db.Get[AppState](ctx, builder.Eq{"id": key})
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if !has {
|
||||
|
||||
@@ -348,8 +348,8 @@ func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddres
|
||||
opts := &TimeLimitCodeOptions{Purpose: TimeLimitCodeActivateEmail, NewEmail: email}
|
||||
data := makeTimeLimitCodeHashData(opts, user)
|
||||
if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) {
|
||||
emailAddress := &EmailAddress{UID: user.ID, Email: email}
|
||||
if has, _ := db.GetEngine(ctx).Get(emailAddress); has {
|
||||
emailAddress, has, _ := db.Get[EmailAddress](ctx, builder.Eq{"uid": user.ID, "email": email})
|
||||
if has {
|
||||
return emailAddress
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// Follow represents relations of user and their followers.
|
||||
@@ -24,7 +26,7 @@ func init() {
|
||||
|
||||
// IsFollowing returns true if user is following followID.
|
||||
func IsFollowing(ctx context.Context, userID, followID int64) bool {
|
||||
has, _ := db.GetEngine(ctx).Get(&Follow{UserID: userID, FollowID: followID})
|
||||
has, _ := db.Exist[Follow](ctx, builder.Eq{"user_id": userID, "follow_id": followID})
|
||||
return has
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// UserOpenID is the list of all OpenID identities of a user.
|
||||
@@ -43,7 +45,7 @@ func isOpenIDUsed(ctx context.Context, uri string) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return db.GetEngine(ctx).Get(&UserOpenID{URI: uri})
|
||||
return db.Exist[UserOpenID](ctx, builder.Eq{"uri": uri})
|
||||
}
|
||||
|
||||
// ErrOpenIDAlreadyUsed represents a "OpenIDAlreadyUsed" kind of error.
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// ErrUserRedirectNotExist represents a "UserRedirectNotExist" kind of error.
|
||||
@@ -50,8 +52,8 @@ func init() {
|
||||
// LookupUserRedirect look up userID if a user has a redirect name
|
||||
func LookupUserRedirect(ctx context.Context, userName string) (int64, error) {
|
||||
userName = strings.ToLower(userName)
|
||||
redirect := &Redirect{LowerName: userName}
|
||||
if has, err := db.GetEngine(ctx).Get(redirect); err != nil {
|
||||
redirect, has, err := db.Get[Redirect](ctx, builder.Eq{"lower_name": userName})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
} else if !has {
|
||||
return 0, ErrUserRedirectNotExist{Name: userName}
|
||||
|
||||
@@ -125,8 +125,7 @@ func GetUserSetting(ctx context.Context, userID int64, key string, def ...string
|
||||
return "", err
|
||||
}
|
||||
|
||||
setting := &Setting{UserID: userID, SettingKey: key}
|
||||
has, err := db.GetEngine(ctx).Get(setting)
|
||||
setting, has, err := db.Get[Setting](ctx, builder.Eq{"user_id": userID, "setting_key": key})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
+21
-6
@@ -1276,8 +1276,7 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
|
||||
email = strings.ToLower(email)
|
||||
// Otherwise, check in alternative list for activated email addresses
|
||||
emailAddress := &EmailAddress{LowerEmail: email, IsActivated: true}
|
||||
has, err := db.GetEngine(ctx).Get(emailAddress)
|
||||
emailAddress, has, err := db.Get[EmailAddress](ctx, builder.Eq{"lower_email": email, "is_activated": true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1297,13 +1296,29 @@ func GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
return nil, ErrUserNotExist{Name: email}
|
||||
}
|
||||
|
||||
func GetIndividualUser(ctx context.Context, user *User) (bool, error) {
|
||||
// FIXME: the design is wrong, empty User fields won't apply, this function should be removed in the future
|
||||
has, err := db.GetEngine(ctx).Get(user)
|
||||
func GetIndividualUserByPrimaryEmail(ctx context.Context, email string) (*User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if len(email) == 0 {
|
||||
return nil, ErrUserNotExist{Name: email}
|
||||
}
|
||||
|
||||
user, has, err := db.Get[User](ctx, builder.Eq{"email": email, "type": UserTypeIndividual})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, ErrUserNotExist{Name: email}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func GetIndividualUserByLoginSource(ctx context.Context, loginType auth.Type, loginSource int64, loginName string) (*User, bool, error) {
|
||||
user, has, err := db.Get[User](ctx, builder.Eq{"login_type": loginType, "login_source": loginSource, "login_name": loginName})
|
||||
if has && user.Type != UserTypeIndividual {
|
||||
has = false
|
||||
user = nil
|
||||
}
|
||||
return has, err
|
||||
return user, has, err
|
||||
}
|
||||
|
||||
// GetUserByOpenID returns the user object by given OpenID if exists.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user