mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-02 13:44:16 +09:00
Compare commits
139
Commits
v29.0.0-dev
...
v28.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f5a3e13e5 | ||
|
|
c50d5fc20d | ||
|
|
dfdb0438ad | ||
|
|
7868ade8f7 | ||
|
|
67904da6a5 | ||
|
|
6f5d66f6dd | ||
|
|
a1c56dd96a | ||
|
|
5a3f06430d | ||
|
|
6c8eaaf9e7 | ||
|
|
c9e1805cc9 | ||
|
|
5ba69f9af4 | ||
|
|
e395b4d9dc | ||
|
|
308dd93484 | ||
|
|
3162dc4cbf | ||
|
|
51736a9706 | ||
|
|
40079c7c09 | ||
|
|
2b11ba861f | ||
|
|
9ad9f99060 | ||
|
|
0964106988 | ||
|
|
8cf0f9798f | ||
|
|
5d737d660b | ||
|
|
5be73fa93a | ||
|
|
3ed5886066 | ||
|
|
12b5af068f | ||
|
|
8e83bb0e61 | ||
|
|
2042caf945 | ||
|
|
79189a959f | ||
|
|
1b8a7c4d85 | ||
|
|
dbe80ac05c | ||
|
|
4b669ca620 | ||
|
|
9d335e8783 | ||
|
|
85a85878c7 | ||
|
|
ef2ceff6cd | ||
|
|
35a6f3d4e0 | ||
|
|
cf3d325e45 | ||
|
|
b140311d54 | ||
|
|
2b0aa6cfd2 | ||
|
|
9d1d5ceddb | ||
|
|
9141a6386f | ||
|
|
a814cf7567 | ||
|
|
2fd12d5f76 | ||
|
|
640b528580 | ||
|
|
3e5256b6ca | ||
|
|
3996c2c520 | ||
|
|
716295015b | ||
|
|
f50a5d0dab | ||
|
|
bc3e228e78 | ||
|
|
193c676946 | ||
|
|
977c9181ad | ||
|
|
197e761471 | ||
|
|
01db50f0bf | ||
|
|
4b743d2e8c | ||
|
|
31653bdb34 | ||
|
|
7365946851 | ||
|
|
254e18107e | ||
|
|
290568a07b | ||
|
|
b758396619 | ||
|
|
9bc4ce88ee | ||
|
|
9bb9a51623 | ||
|
|
5838cc8172 | ||
|
|
829b49c1ae | ||
|
|
58031cdc97 | ||
|
|
1eee73e9bd | ||
|
|
dc30660291 | ||
|
|
6bc0e8cd57 | ||
|
|
cfcbdf41e2 | ||
|
|
6195cda58f | ||
|
|
5251cd572f | ||
|
|
ce8912c8ff | ||
|
|
0e2a188d8e | ||
|
|
95d6b572fe | ||
|
|
c4e9d96162 | ||
|
|
96cb6cd16f | ||
|
|
02b18a0c8e | ||
|
|
1ffe46d2a4 | ||
|
|
26f537dea6 | ||
|
|
74e7e813c3 | ||
|
|
3f0aa7a01e | ||
|
|
7e1e32b42e | ||
|
|
63669003eb | ||
|
|
f5fd936cbb | ||
|
|
d87bbd3dd4 | ||
|
|
7a03ec3a4c | ||
|
|
0132cb82c2 | ||
|
|
33aaf0cfa3 | ||
|
|
6064554b8d | ||
|
|
d29364a4f6 | ||
|
|
998a06627e | ||
|
|
250d18d5f6 | ||
|
|
c26daf578f | ||
|
|
5371e788fe | ||
|
|
0001e58e6b | ||
|
|
79c4237cb2 | ||
|
|
20f73b2230 | ||
|
|
175bc89bf9 | ||
|
|
793e77b073 | ||
|
|
5018ae1b29 | ||
|
|
c60b13b2dd | ||
|
|
27bd022b34 | ||
|
|
4ebd5e319b | ||
|
|
d6945fb0b5 | ||
|
|
69a2bfcfbd | ||
|
|
15a0ea83ca | ||
|
|
30cc41ff97 | ||
|
|
7f672160de | ||
|
|
e27f69bc88 | ||
|
|
a67171af37 | ||
|
|
c7bd37e901 | ||
|
|
0a4078558e | ||
|
|
e66e773a6f | ||
|
|
08c123299a | ||
|
|
219b0e7c17 | ||
|
|
02fd18970b | ||
|
|
efee6d7ce4 | ||
|
|
0ca36eeb3c | ||
|
|
3ee1924069 | ||
|
|
99ac787400 | ||
|
|
1856fef7a8 | ||
|
|
7440f1e452 | ||
|
|
638c75a4d3 | ||
|
|
575754cbeb | ||
|
|
f24f3e1eda | ||
|
|
1f615a406f | ||
|
|
9c77a87908 | ||
|
|
4c3df3ab3c | ||
|
|
565957503c | ||
|
|
e48591ba64 | ||
|
|
f2a08e0261 | ||
|
|
532fb8f4f4 | ||
|
|
25416e9be7 | ||
|
|
1c4fff096f | ||
|
|
d16daed041 | ||
|
|
2d58c8c3df | ||
|
|
1e28bb1bd7 | ||
|
|
f0e8c3c3d0 | ||
|
|
7c58b73243 | ||
|
|
6546382f4e | ||
|
|
0930bd71fe | ||
|
|
15b8a5805a |
No files matched your search
@@ -1,59 +0,0 @@
|
|||||||
# The full repository name
|
|
||||||
repo: go-gitea/gitea
|
|
||||||
|
|
||||||
# Service type (gitea or github)
|
|
||||||
service: github
|
|
||||||
|
|
||||||
# Base URL for Gitea instance if using gitea service type (optional)
|
|
||||||
# Default: https://gitea.com
|
|
||||||
base-url:
|
|
||||||
|
|
||||||
# Changelog groups and which labeled PRs to add to each group
|
|
||||||
groups:
|
|
||||||
-
|
|
||||||
name: BREAKING
|
|
||||||
labels:
|
|
||||||
- pr/breaking
|
|
||||||
-
|
|
||||||
name: SECURITY
|
|
||||||
labels:
|
|
||||||
- topic/security
|
|
||||||
-
|
|
||||||
name: FEATURES
|
|
||||||
labels:
|
|
||||||
- type/feature
|
|
||||||
-
|
|
||||||
name: ENHANCEMENTS
|
|
||||||
labels:
|
|
||||||
- type/enhancement
|
|
||||||
-
|
|
||||||
name: PERFORMANCE
|
|
||||||
labels:
|
|
||||||
- performance/memory
|
|
||||||
- performance/speed
|
|
||||||
- performance/bigrepo
|
|
||||||
- performance/cpu
|
|
||||||
-
|
|
||||||
name: BUGFIXES
|
|
||||||
labels:
|
|
||||||
- type/bug
|
|
||||||
|
|
||||||
-
|
|
||||||
name: TESTING
|
|
||||||
labels:
|
|
||||||
- type/testing
|
|
||||||
-
|
|
||||||
name: BUILD
|
|
||||||
labels:
|
|
||||||
- topic/build
|
|
||||||
- topic/code-linting
|
|
||||||
-
|
|
||||||
name: DOCS
|
|
||||||
labels:
|
|
||||||
- type/docs
|
|
||||||
-
|
|
||||||
name: MISC
|
|
||||||
default: true
|
|
||||||
|
|
||||||
# regex indicating which labels to skip for the changelog
|
|
||||||
skip-labels: skip-changelog|backport\/.+
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Release
|
||||||
|
description: Track a Gitea release (for release managers).
|
||||||
|
title: "Release Gitea "
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Follow the [release management guide](https://github.com/go-gitea/gitea/blob/main/docs/release-management.md).
|
||||||
|
Set the issue title and milestone to the version being released. Replace the examples below and mark inapplicable tasks as such.
|
||||||
|
CI signs the tag, generates release notes, and publishes binaries and containers. Track verification here; no manual changelog PR or release upload is needed.
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
placeholder: "28.0.1"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: branch
|
||||||
|
attributes:
|
||||||
|
label: Release branch
|
||||||
|
placeholder: "release/v28"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Release checklist
|
||||||
|
description: Keep workflow runs, release URLs, and follow-up PRs alongside the relevant tasks.
|
||||||
|
value: |
|
||||||
|
### Preparation
|
||||||
|
|
||||||
|
- [ ] Resolve release blockers and confirm milestone issues and PRs are resolved or deferred.
|
||||||
|
- [ ] Confirm required backports are merged and release branch CI passes.
|
||||||
|
- [ ] For a new release line, create the release branch and tag its fork point on main with the next version's -dev tag.
|
||||||
|
|
||||||
|
### Release
|
||||||
|
|
||||||
|
- [ ] Run https://github.com/go-gitea/gitea/actions/workflows/release-create-tag.yml on the release branch with the selected version and obtain maintainer approval.
|
||||||
|
- [ ] Confirm https://github.com/go-gitea/gitea/actions/workflows/release-tag-version.yml succeeds for the new tag (binaries and containers).
|
||||||
|
- [ ] Verify the public GitHub release, generated notes, binary attachments, and signatures at https://github.com/go-gitea/gitea/releases.
|
||||||
|
- [ ] Verify binaries and signatures at https://dl.gitea.com/gitea/ for this version.
|
||||||
|
- [ ] Verify versioned regular and rootless images on Docker Hub and GHCR, and smoke-test the release.
|
||||||
|
|
||||||
|
### Follow-up
|
||||||
|
|
||||||
|
- [ ] Verify the automated https://dl.gitea.com/gitea/version.json update, where applicable to this release line.
|
||||||
|
- [ ] Verify automated Helm chart and Terraform provider update PRs and follow up if needed.
|
||||||
|
- [ ] Check Homebrew and Snap availability; record any outstanding packaging follow-up.
|
||||||
|
- [ ] Confirm documentation reflects the release, where applicable.
|
||||||
|
- [ ] Confirm and merge the release blog post, if planned: https://gitea.com/gitea/blog.
|
||||||
|
- [ ] Announce the release in Discord #announcements.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: notes
|
||||||
|
attributes:
|
||||||
|
label: Blockers and notes
|
||||||
|
description: Link outstanding work or release-specific checks using full URLs. Do not include undisclosed security details.
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
|
- uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||||
gitea_fork: giteabot/gitea
|
gitea_fork: giteabot/gitea
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: giteabot-review
|
||||||
|
|
||||||
|
# Relays PR reviews to giteabot.yml through its workflow_run trigger, because review
|
||||||
|
# runs on fork PRs get no secrets and a read-only token. The job itself does nothing.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_review:
|
||||||
|
types:
|
||||||
|
- submitted
|
||||||
|
- edited
|
||||||
|
- dismissed
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
relay:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- run: "true"
|
||||||
@@ -20,13 +20,12 @@ on:
|
|||||||
- closed
|
- closed
|
||||||
- review_requested
|
- review_requested
|
||||||
- review_request_removed
|
- review_request_removed
|
||||||
# Review events keep review-derived state such as lgtm labels and status checks
|
# Reviews arrive through giteabot-review because fork PR review runs get no secrets
|
||||||
# in sync after approvals, edits, or dismissals.
|
workflow_run:
|
||||||
pull_request_review:
|
workflows:
|
||||||
|
- giteabot-review
|
||||||
types:
|
types:
|
||||||
- submitted
|
- requested
|
||||||
- edited
|
|
||||||
- dismissed
|
|
||||||
# Periodic maintenance is still useful as a backstop for queue cleanup and
|
# Periodic maintenance is still useful as a backstop for queue cleanup and
|
||||||
# other housekeeping, even though main pushes now trigger it promptly.
|
# other housekeeping, even though main pushes now trigger it promptly.
|
||||||
schedule:
|
schedule:
|
||||||
@@ -38,17 +37,17 @@ on:
|
|||||||
checks:
|
checks:
|
||||||
description: Comma-separated list of non-backport checks to run
|
description: Comma-separated list of non-backport checks to run
|
||||||
required: false
|
required: false
|
||||||
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
|
default: backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ format('{0}-{1}', github.workflow, (github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review') && format('pr-{0}', github.event.pull_request.number) || 'maintenance') }}
|
group: ${{ format('{0}-{1}', github.workflow, github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_run' && format('review-{0}', github.event.workflow_run.head_sha) || 'maintenance') }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
giteabot:
|
giteabot:
|
||||||
if: github.repository == 'go-gitea/gitea'
|
if: github.repository == 'go-gitea/gitea' && (github.event_name != 'workflow_run' || github.event.workflow_run.event == 'pull_request_review')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
@@ -57,9 +56,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
statuses: write
|
statuses: write
|
||||||
steps:
|
steps:
|
||||||
# pull_request_review runs without repository secrets on fork PRs, so fall
|
- uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
|
||||||
# back to the workflow token for the non-backport checks handled here.
|
|
||||||
- uses: go-gitea/giteabot@4c9d4d3fd913b5c35f59dcc0b004a3d11d5b22bf # v1.0.7
|
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITEABOT_TOKEN || github.token }}
|
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||||
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
checks: ${{ github.event.inputs.checks || 'backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: release-create-tag
|
||||||
|
run-name: Release v${{ inputs.version }} from ${{ github.ref_name }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Version to release, for example 28.0.1
|
||||||
|
required: true
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tag:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: release-signing
|
||||||
|
deployment: false
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
- uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0
|
||||||
|
with:
|
||||||
|
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
||||||
|
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
||||||
|
git_user_signingkey: true
|
||||||
|
git_committer_email: teabot@gitea.io
|
||||||
|
- env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
[[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||||
|
git tag -s -m "v$VERSION" "v$VERSION"
|
||||||
|
git push origin tag "v$VERSION"
|
||||||
@@ -37,8 +37,15 @@ jobs:
|
|||||||
# retry snapcraft uploads which can be flaky
|
# retry snapcraft uploads which can be flaky
|
||||||
- name: Publish snap
|
- name: Publish snap
|
||||||
run: |
|
run: |
|
||||||
|
# part-gitea-pull.sh decides what is built, see the comments there:
|
||||||
|
# * grade devel (main or a prerelease tag) goes to latest/edge
|
||||||
|
# * grade stable (a new release tag) goes to latest/stable, and to latest/candidate which marks it as released
|
||||||
|
channel=latest/edge
|
||||||
|
if unsquashfs -cat "$SNAP" meta/snap.yaml | grep -qx 'grade: stable'; then
|
||||||
|
channel=latest/stable,latest/candidate
|
||||||
|
fi
|
||||||
for attempt in 1 2 3 4 5; do
|
for attempt in 1 2 3 4 5; do
|
||||||
snapcraft upload "$SNAP" --release latest/edge && exit 0
|
snapcraft upload "$SNAP" --release "$channel" && exit 0
|
||||||
echo "::warning::snap upload attempt $attempt failed, retrying in 15s"
|
echo "::warning::snap upload attempt $attempt failed, retrying in 15s"
|
||||||
sleep 15
|
sleep 15
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
name: release-tag-rc
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v1*-rc*"
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
binary:
|
|
||||||
runs-on: namespace-profile-gitea-release-binary
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
check-latest: true
|
|
||||||
cache: false
|
|
||||||
- uses: ./.github/actions/node-setup
|
|
||||||
- run: make deps-frontend deps-backend
|
|
||||||
- run: make release
|
|
||||||
- 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
|
|
||||||
with:
|
|
||||||
gpg_private_key: ${{ secrets.GPGSIGN_KEY }}
|
|
||||||
passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }}
|
|
||||||
- name: sign binaries
|
|
||||||
env:
|
|
||||||
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}
|
|
||||||
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 the object storage
|
|
||||||
- name: Get cleaned branch name
|
|
||||||
id: clean_name
|
|
||||||
env:
|
|
||||||
REF: ${{ github.ref }}
|
|
||||||
run: |
|
|
||||||
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
|
|
||||||
echo "Cleaned name is ${REF_NAME}"
|
|
||||||
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: upload binaries to cloudflare r2
|
|
||||||
env:
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
|
||||||
AWS_DEFAULT_REGION: auto
|
|
||||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
|
||||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
|
||||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
|
||||||
run: |
|
|
||||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
|
||||||
- name: Install GH CLI
|
|
||||||
uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0
|
|
||||||
with:
|
|
||||||
gh-cli-version: 2.39.1
|
|
||||||
- name: create github release
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
gh release create "$TAG" --title "$TAG" --draft --notes-from-tag dist/release/*
|
|
||||||
|
|
||||||
container:
|
|
||||||
runs-on: namespace-profile-gitea-release-docker
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write # to publish to ghcr.io
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
|
|
||||||
with:
|
|
||||||
cache-image: false
|
|
||||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
|
||||||
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
|
||||||
id: meta
|
|
||||||
with:
|
|
||||||
images: |-
|
|
||||||
gitea/gitea
|
|
||||||
ghcr.io/go-gitea/gitea
|
|
||||||
flavor: |
|
|
||||||
latest=false
|
|
||||||
# 1.2.3-rc0
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
annotations: |
|
|
||||||
org.opencontainers.image.authors="maintainers@gitea.io"
|
|
||||||
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
|
||||||
id: meta_rootless
|
|
||||||
with:
|
|
||||||
images: |-
|
|
||||||
gitea/gitea
|
|
||||||
ghcr.io/go-gitea/gitea
|
|
||||||
# each tag below will have the suffix of -rootless
|
|
||||||
flavor: |
|
|
||||||
latest=false
|
|
||||||
suffix=-rootless
|
|
||||||
# 1.2.3-rc0
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
annotations: |
|
|
||||||
org.opencontainers.image.authors="maintainers@gitea.io"
|
|
||||||
- name: Login to Docker Hub
|
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
- name: Login to GHCR using PAT
|
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.repository_owner }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: build regular container image
|
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
annotations: ${{ steps.meta.outputs.annotations }}
|
|
||||||
- name: build rootless container image
|
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/riscv64
|
|
||||||
push: true
|
|
||||||
file: Dockerfile.rootless
|
|
||||||
tags: ${{ steps.meta_rootless.outputs.tags }}
|
|
||||||
annotations: ${{ steps.meta_rootless.outputs.annotations }}
|
|
||||||
@@ -3,9 +3,8 @@ name: release-tag-version
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v1.*"
|
- "v[0-9]*"
|
||||||
- "!v1*-rc*"
|
- "!v[0-9]*-*"
|
||||||
- "!v1*-dev"
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -73,12 +72,23 @@ jobs:
|
|||||||
uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0
|
uses: dev-hanz-ops/install-gh-cli-action@6089bdde54118ad7ca3d22053eb2d69387fd2779 # v0.3.0
|
||||||
with:
|
with:
|
||||||
gh-cli-version: 2.39.1
|
gh-cli-version: 2.39.1
|
||||||
|
- id: range
|
||||||
|
run: |
|
||||||
|
previous=$(git tag --list --sort=-v:refname | grep -xE 'v[0-9]+\.[0-9]+\.[0-9]+' | grep -A1 -xF "$GITHUB_REF_NAME" | tail -1) # highest stable version below this one
|
||||||
|
echo "range=$previous..$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: skip reverts and reverted commits in changelog
|
||||||
|
env:
|
||||||
|
RANGE: ${{ steps.range.outputs.range }}
|
||||||
|
run: ./tools/generate-cliffignore.sh "$RANGE" | sort -u > .cliffignore
|
||||||
|
- uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1
|
||||||
|
with:
|
||||||
|
args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }}
|
||||||
- name: create github release
|
- name: create github release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
gh release create "$TAG" --title "$TAG" --notes-from-tag dist/release/*
|
gh release create "$TAG" --title "$TAG" --notes-file git-cliff/CHANGELOG.md dist/release/*
|
||||||
|
|
||||||
container:
|
container:
|
||||||
runs-on: namespace-profile-gitea-release-docker
|
runs-on: namespace-profile-gitea-release-docker
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
-6614
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -169,7 +169,7 @@ In the PR title, describe the problem you are fixing, not how you are fixing it.
|
|||||||
Use the first comment as a summary of your PR. \
|
Use the first comment as a summary of your PR. \
|
||||||
In the PR summary, you can describe exactly how you are fixing this problem.
|
In the PR summary, you can describe exactly how you are fixing this problem.
|
||||||
|
|
||||||
PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message:
|
PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message and release notes entry:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
type(scope)!: subject
|
type(scope)!: subject
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
|
|||||||
|
|
||||||
AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go
|
AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go
|
||||||
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go
|
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 # renovate: datasource=go
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go
|
||||||
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # 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.36.6 # renovate: datasource=go
|
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6 # renovate: datasource=go
|
||||||
@@ -136,7 +136,7 @@ WEB_DIRS := web_src/js web_src/css
|
|||||||
|
|
||||||
ESLINT_FILES := web_src/js tools *.ts tests/e2e
|
ESLINT_FILES := web_src/js tools *.ts tests/e2e
|
||||||
STYLELINT_FILES := web_src/css web_src/js/components/*.vue
|
STYLELINT_FILES := web_src/css web_src/js/components/*.vue
|
||||||
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(filter-out CHANGELOG.md, $(wildcard *.go *.md *.yml *.yaml *.toml))
|
SPELLCHECK_FILES := $(GO_DIRS) $(WEB_DIRS) templates options/locale/locale_en-US.json .github $(wildcard *.go *.md *.yml *.yaml *.toml)
|
||||||
EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json
|
EDITORCONFIG_FILES := templates .github/workflows options/locale/locale_en-US.json
|
||||||
|
|
||||||
GO_SOURCES := $(wildcard *.go)
|
GO_SOURCES := $(wildcard *.go)
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ See [app.example.ini](https://github.com/go-gitea/gitea/blob/main/custom/conf/ap
|
|||||||
|
|
||||||
**Where can I find the security patches?**
|
**Where can I find the security patches?**
|
||||||
|
|
||||||
In the [release log](https://github.com/go-gitea/gitea/releases) or the [change log](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md), search for the keyword `SECURITY` to find the security patches.
|
Check the [release notes](https://github.com/go-gitea/gitea/releases) and [security advisories](https://github.com/go-gitea/gitea/security/advisories) for security patches.
|
||||||
|
|
||||||
(more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq))
|
(more FAQs are listed in [FAQ documentation](https://docs.gitea.com/help/faq))
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ Gitea 的发音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea
|
|||||||
|
|
||||||
**在哪里可以找到安全补丁?**
|
**在哪里可以找到安全补丁?**
|
||||||
|
|
||||||
在 [发布日志](https://github.com/go-gitea/gitea/releases) 或 [变更日志](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索关键词 `SECURITY` 以找到安全补丁。
|
在 [发布日志](https://github.com/go-gitea/gitea/releases) 中,搜索关键词 `SECURITY` 以找到安全补丁。
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -125,7 +125,7 @@ Gitea 的發音是 [/ɡɪ’ti:/](https://youtu.be/EM71-2uDAoY),就像 "gi-tea
|
|||||||
|
|
||||||
**在哪裡可以找到安全補丁?**
|
**在哪裡可以找到安全補丁?**
|
||||||
|
|
||||||
在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 或 [變更日誌](https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。
|
在 [發佈日誌](https://github.com/go-gitea/gitea/releases) 中,搜索關鍵詞 `SECURITY` 以找到安全補丁。
|
||||||
|
|
||||||
## 許可證
|
## 許可證
|
||||||
|
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
[git]
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^(chore|ci)(\\([\\w/.-]+\\))?!?: ", skip = true },
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^enhance", group = "Enhancements" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = ".*", group = "Miscellaneous" },
|
||||||
|
]
|
||||||
@@ -33,7 +33,7 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := user_model.GetAllUsers(ctx)
|
users, err := user_model.GetAllUsersForAdminListing(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -41,17 +41,17 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
|
|||||||
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
|
||||||
|
|
||||||
if c.IsSet("admin") {
|
if c.IsSet("admin") {
|
||||||
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n")
|
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tType\n")
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if u.IsAdmin {
|
if u.IsAdmin {
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive)
|
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%s\n", u.ID, u.Name, u.Email, u.IsActive, u.Type.DisplayName())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
twofa := user_model.UserList(users).GetTwoFaStatus(ctx)
|
twofa := user_model.UserList(users).GetTwoFaStatus(ctx)
|
||||||
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n")
|
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\tType\n")
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID])
|
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\t%s\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID], u.Type.DisplayName())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -87,7 +87,7 @@ echo "Checking currently installed version..."
|
|||||||
current=$(giteacmd --version | cut -d ' ' -f 3)
|
current=$(giteacmd --version | cut -d ' ' -f 3)
|
||||||
[[ "$current" == "$giteaversion" ]] && echo "$current is already installed, stopping." && exit 0
|
[[ "$current" == "$giteaversion" ]] && echo "$current is already installed, stopping." && exit 0
|
||||||
if [[ -z "${no_confirm:-}" ]]; then
|
if [[ -z "${no_confirm:-}" ]]; then
|
||||||
echo "Make sure to read the changelog first: https://github.com/go-gitea/gitea/blob/main/CHANGELOG.md"
|
echo "Make sure to read the changelog first: https://github.com/go-gitea/gitea/releases"
|
||||||
echo "Are you ready to update Gitea from ${current} to ${giteaversion}? (y/N)"
|
echo "Are you ready to update Gitea from ${current} to ${giteaversion}? (y/N)"
|
||||||
read -r confirm
|
read -r confirm
|
||||||
[[ "$confirm" == "y" ]] || [[ "$confirm" == "Y" ]] || exit 1
|
[[ "$confirm" == "y" ]] || [[ "$confirm" == "Y" ]] || exit 1
|
||||||
|
|||||||
@@ -536,7 +536,7 @@ INTERNAL_TOKEN =
|
|||||||
;CONTENT_SECURITY_POLICY_GENERAL =
|
;CONTENT_SECURITY_POLICY_GENERAL =
|
||||||
;;
|
;;
|
||||||
;; Egress mode toggles between strictness of outgoing requests:
|
;; Egress mode toggles between strictness of outgoing requests:
|
||||||
;; Lax requires addresses to be allowed only if they are in private ranges, it allows all public ones
|
;; Lax requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to be allowed, it allows all public ones
|
||||||
;; Strict requires an explicit allow of all addresses
|
;; Strict requires an explicit allow of all addresses
|
||||||
; EGRESS_MODE = lax
|
; EGRESS_MODE = lax
|
||||||
;;
|
;;
|
||||||
@@ -551,10 +551,12 @@ INTERNAL_TOKEN =
|
|||||||
;; a bracketed set of ports and ranges, | separated: *.mydomain.com:[80|443|3000-3010]
|
;; a bracketed set of ports and ranges, | separated: *.mydomain.com:[80|443|3000-3010]
|
||||||
;; all ports: *.mydomain.com:*
|
;; all ports: *.mydomain.com:*
|
||||||
;; A portless entry covers all ports in Lax mode, only 80 and 443 in Strict mode
|
;; A portless entry covers all ports in Lax mode, only 80 and 443 in Strict mode
|
||||||
;; Port specs apply only where the list is consulted: in Lax mode that is private, loopback and CGNAT
|
;; Port specs apply only where the list is consulted: in Lax mode that is non-public targets alone,
|
||||||
;; targets alone, public targets are allowed on every port whatever the list says. In Strict mode every
|
;; public targets are allowed on every port whatever the list says. In Strict mode every
|
||||||
;; target is checked, so ports restrict public hosts too.
|
;; target is checked, so ports restrict public hosts too.
|
||||||
;; Reserved addresses like link-local and cloud metadata are denied
|
;; Non-public targets need an IP or built-in entry, a host name entry alone never covers them.
|
||||||
|
;; Reserved addresses (the IPv4-embedding NAT64, Teredo and 6to4 ranges, this-network, multicast and
|
||||||
|
;; broadcast) are denied whatever the list says. To reach them configure an HTTP proxy
|
||||||
;; This list is enforced on direct connections only. When an HTTP proxy is configured, restricting the proxied target is the proxy server's responsibility.
|
;; 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 =
|
;ALLOWED_HOST_LIST =
|
||||||
|
|
||||||
@@ -636,7 +638,8 @@ ENABLED = true
|
|||||||
;; * https://github.com/git-ecosystem/git-credential-manager
|
;; * https://github.com/git-ecosystem/git-credential-manager
|
||||||
;; * https://gitea.com/gitea/tea
|
;; * https://gitea.com/gitea/tea
|
||||||
;; * Gitea App (the official Gitea mobile app)
|
;; * Gitea App (the official Gitea mobile app)
|
||||||
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app
|
;; * https://gitea.com/gitea/gitea-vscode
|
||||||
|
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app, gitea-vscode
|
||||||
;;
|
;;
|
||||||
;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes.
|
;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes.
|
||||||
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
|
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
|
||||||
@@ -808,7 +811,7 @@ LEVEL = Info
|
|||||||
;[git.config]
|
;[git.config]
|
||||||
;diff.algorithm = histogram
|
;diff.algorithm = histogram
|
||||||
;core.logAllRefUpdates = true
|
;core.logAllRefUpdates = true
|
||||||
;gc.reflogExpire = 90
|
;maintenance.strategy = gc
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -1096,8 +1099,9 @@ LEVEL = Info
|
|||||||
;; Force ssh:// clone url instead of scp-style uri when default SSH port is used
|
;; Force ssh:// clone url instead of scp-style uri when default SSH port is used
|
||||||
;USE_COMPAT_SSH_URI = false
|
;USE_COMPAT_SSH_URI = false
|
||||||
;;
|
;;
|
||||||
;; Value for the "go get" request returns the repository url as https or ssh, default is https
|
;; Scheme of the returned URL for the "go get" response.
|
||||||
;GO_GET_CLONE_URL_PROTOCOL = https
|
;; Default is "https" if DISABLE_HTTP_GIT=false or SSH is disabled, otherwise "ssh".
|
||||||
|
;GO_GET_CLONE_URL_PROTOCOL =
|
||||||
;;
|
;;
|
||||||
;; Close issues as long as a commit on any branch marks it as fixed
|
;; Close issues as long as a commit on any branch marks it as fixed
|
||||||
;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false
|
;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false
|
||||||
@@ -3080,7 +3084,7 @@ LEVEL = Info
|
|||||||
;LOG_RETENTION_DAYS = 365
|
;LOG_RETENTION_DAYS = 365
|
||||||
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
|
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
|
||||||
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
|
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
|
||||||
;; Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step.
|
;; Workflows can request a shorter retention period, down to 1 day.
|
||||||
;ARTIFACT_RETENTION_DAYS = 90
|
;ARTIFACT_RETENTION_DAYS = 90
|
||||||
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
|
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
|
||||||
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760
|
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ Operations that must roll back together should run inside `db.WithTx()` (or
|
|||||||
Functions that participate in a transaction take a `context.Context` as their first
|
Functions that participate in a transaction take a `context.Context` as their first
|
||||||
parameter so the transaction can be propagated.
|
parameter so the transaction can be propagated.
|
||||||
|
|
||||||
|
PostgreSQL, MySQL and MSSQL (via `READ_COMMITTED_SNAPSHOT`) read the last committed
|
||||||
|
row version, so reads never wait for writers. Guard read-then-write logic with a
|
||||||
|
conditional `UPDATE` or a lock.
|
||||||
|
|
||||||
### XORM gotchas
|
### XORM gotchas
|
||||||
|
|
||||||
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
|
- Never call `x.Update(exemplar)` without an explicit `WHERE` clause — it updates
|
||||||
|
|||||||
+10
-26
@@ -8,10 +8,9 @@ This document describes the release cycle, backports, versioning, and the releas
|
|||||||
|
|
||||||
We backport PRs given the following circumstances:
|
We backport PRs given the following circumstances:
|
||||||
|
|
||||||
1. Feature freeze is active, but `<version>-rc0` has not been released yet. Here, we backport as much as possible. <!-- TODO: Is that our definition with the new backport bot? -->
|
1. We backport bug- and security-fixes and small enhancements. Large changes such as refactors are not backported.
|
||||||
2. `rc0` has been released. Here, we only backport bug- and security-fixes, and small enhancements. Large PRs such as refactors are not backported anymore. <!-- TODO: Is that our definition with the new backport bot? -->
|
2. We never backport new features.
|
||||||
3. We never backport new features.
|
3. We never backport breaking changes except when
|
||||||
4. We never backport breaking changes except when
|
|
||||||
1. The breaking change has no effect on the vast majority of users
|
1. The breaking change has no effect on the vast majority of users
|
||||||
2. The component triggering the breaking change is marked as experimental
|
2. The component triggering the breaking change is marked as experimental
|
||||||
|
|
||||||
@@ -53,7 +52,6 @@ We use a release schedule so work, stabilization, and releases stay predictable.
|
|||||||
### Cadence
|
### Cadence
|
||||||
|
|
||||||
- Aim for a major release about every three or four months.
|
- Aim for a major release about every three or four months.
|
||||||
- Roughly two or three months of general development, then about one month of testing and polish called the **release freeze**.
|
|
||||||
- *Starting with v1.26 the release cycle will be more predictable and follow a more regular schedule.*
|
- *Starting with v1.26 the release cycle will be more predictable and follow a more regular schedule.*
|
||||||
|
|
||||||
### Release schedule
|
### Release schedule
|
||||||
@@ -65,16 +63,6 @@ We will try to publish a new major version every three months:
|
|||||||
- v1.28.0 in September 2026
|
- v1.28.0 in September 2026
|
||||||
- v1.29.0 in December 2026
|
- v1.29.0 in December 2026
|
||||||
|
|
||||||
#### How is the release handled?
|
|
||||||
- The release manager will tag the release candidate (e.g. `v1.26.0-rc0`) and publish it for testing in the **first week of the release month**.
|
|
||||||
- If there are no major issues, the release manager will check with the other maintainers and then tag the final release (e.g. `v1.26.0`) in the **one or two weeks following the release candidate**.
|
|
||||||
|
|
||||||
### Feature freeze
|
|
||||||
|
|
||||||
- Merge feature PRs before the freeze when you can.
|
|
||||||
- Feature PRs still open at the freeze move to the next milestone. Watch Discord for the freeze announcement.
|
|
||||||
- During the freeze, a **release branch** takes fixes backported from `main`. Release candidates ship for testing; the final release for that line is maintained from that branch.
|
|
||||||
|
|
||||||
### Patch releases
|
### Patch releases
|
||||||
|
|
||||||
During a cycle we may ship patch releases for an older line. For example, if the latest release is v1.2, we can still publish v1.1.1 after v1.1.0.
|
During a cycle we may ship patch releases for an older line. For example, if the latest release is v1.2, we can still publish v1.1.1 after v1.1.0.
|
||||||
@@ -99,17 +87,13 @@ be reviewed by two maintainers and must pass the automatic tests.
|
|||||||
|
|
||||||
## Releasing Gitea
|
## Releasing Gitea
|
||||||
|
|
||||||
- Let MAJOR, MINOR and PATCH be Major, Minor and Patch version numbers, PATCH should be rc1, rc2, 0, 1, ...... MAJOR.MINOR will be kept the same as milestones on github or gitea in future.
|
Track each release using the [release issue template](https://github.com/go-gitea/gitea/issues/new?template=release.yaml).
|
||||||
- Before releasing, confirm all the version's milestone issues or PRs has been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours.
|
|
||||||
- If this is a big version first you have to create PR for changelog on branch `main` with PRs with label `changelog` and after it has been merged do following steps:
|
- Before releasing, confirm all the version's milestone issues or PRs have been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours.
|
||||||
- Create `-dev` tag as `git tag -s -F release.notes vMAJOR.MINOR.0-dev` and push the tag as `git push origin vMAJOR.MINOR.0-dev`.
|
- When creating a release branch, tag its fork point on `main` as the next version's `-dev` tag, e.g. `v30.0.0-dev` for `release/v29`.
|
||||||
- When CI has finished building tag then you have to create a new branch named `release/vMAJOR.MINOR`
|
- In the GitHub Actions tab, open the `release-create-tag` workflow, click "Run workflow", select the release branch and enter a version such as `28.0.1`. After maintainer approval, it pushes a signed tag and CI publishes the release with generated notes.
|
||||||
- If it is bugfix version create PR for changelog on branch `release/vMAJOR.MINOR` and wait till it is reviewed and merged.
|
- Optionally send a PR to the [blog repository](https://gitea.com/gitea/blog) announcing the release.
|
||||||
- Add a tag as `git tag -s -F release.notes vMAJOR.MINOR.PATCH`, release.notes file could be a temporary file to only include the changelog this version which you added to `CHANGELOG.md`.
|
|
||||||
- And then push the tag as `git push origin vMAJOR.MINOR.$`. CI will automatically create a release and upload all the compiled binary. (But currently it doesn't add the release notes automatically. Maybe we should fix that.)
|
|
||||||
- If needed send a frontport PR for the changelog to branch `main` and update the version in `docs/config.yaml` to refer to the new version.
|
|
||||||
- Send PR to [blog repository](https://gitea.com/gitea/blog) announcing the release.
|
|
||||||
- Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed:
|
- Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed:
|
||||||
- bump the version of https://dl.gitea.com/gitea/version.json
|
- verify the automated update of https://dl.gitea.com/gitea/version.json, where applicable to the release line
|
||||||
- merge the blog post PR
|
- merge the blog post PR
|
||||||
- announce the release in discord `#announcements`
|
- announce the release in discord `#announcements`
|
||||||
@@ -2,7 +2,7 @@ module gitea.dev
|
|||||||
|
|
||||||
go 1.27
|
go 1.27
|
||||||
|
|
||||||
toolchain go1.27.1
|
toolchain go1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.21.0
|
connectrpc.com/connect v1.21.0
|
||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
||||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
||||||
gitea.dev/actionslib v1.2.1
|
gitea.dev/actionslib v1.2.1
|
||||||
gitea.dev/sdk v1.2.0
|
gitea.dev/sdk v1.3.0
|
||||||
github.com/42wim/httpsig v1.2.4
|
github.com/42wim/httpsig v1.2.4
|
||||||
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
|
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
|
||||||
github.com/Azure/go-ntlmssp v0.1.1
|
github.com/Azure/go-ntlmssp v0.1.1
|
||||||
@@ -94,22 +94,22 @@ require (
|
|||||||
github.com/yuin/goldmark v1.8.6
|
github.com/yuin/goldmark v1.8.6
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
|
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/image v0.46.0
|
golang.org/x/image v0.46.0
|
||||||
golang.org/x/mod v0.41.0
|
golang.org/x/mod v0.41.0
|
||||||
golang.org/x/net v0.59.0
|
golang.org/x/net v0.62.0
|
||||||
golang.org/x/oauth2 v0.37.0
|
golang.org/x/oauth2 v0.37.0
|
||||||
golang.org/x/sync v0.23.0
|
golang.org/x/sync v0.24.0
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/text v0.42.0
|
golang.org/x/text v0.43.0
|
||||||
google.golang.org/grpc v1.84.0
|
google.golang.org/grpc v1.84.0
|
||||||
google.golang.org/protobuf v1.36.12
|
google.golang.org/protobuf v1.36.12
|
||||||
gopkg.in/ini.v1 v1.67.3
|
gopkg.in/ini.v1 v1.67.3
|
||||||
modernc.org/sqlite v1.59.0
|
modernc.org/sqlite v1.59.0
|
||||||
mvdan.cc/xurls/v2 v2.6.0
|
mvdan.cc/xurls/v2 v2.6.0
|
||||||
xorm.io/builder v0.3.13
|
xorm.io/builder v0.3.13
|
||||||
xorm.io/xorm v1.4.1
|
xorm.io/xorm v1.4.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -252,7 +252,7 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.49.0 // indirect
|
golang.org/x/tools v0.51.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
modernc.org/libc v1.75.7 // indirect
|
modernc.org/libc v1.75.7 // indirect
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGq
|
|||||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||||
gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
|
gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
|
||||||
gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
|
gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
|
||||||
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
|
gitea.dev/sdk v1.3.0 h1:4U95+Ou6WHCQiRPDEyAzvl1Q7cxg5RUTWkqmOXr5w4I=
|
||||||
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
|
gitea.dev/sdk v1.3.0/go.mod h1:85EBqSAqUn1KlFCk40y3sTjWjBsB/KouL3slGXMA/2o=
|
||||||
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||||
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||||
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 h1:3Fcz1QzlS7Jv4FT2KI3cHNSZL+KPN3dXxurn9f3YL/Y=
|
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 h1:3Fcz1QzlS7Jv4FT2KI3cHNSZL+KPN3dXxurn9f3YL/Y=
|
||||||
@@ -672,8 +672,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
|||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525 h1:FtzHH3s777Nx50YcPAcodAPm1mNakc228nMYbr3f+90=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -686,8 +686,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
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/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||||
@@ -714,8 +714,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -728,8 +728,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -756,8 +756,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -767,8 +767,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -779,8 +779,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -790,8 +790,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -862,5 +862,5 @@ pgregory.net/rapid v0.4.2 h1:lsi9jhvZTYvzVpeG93WWgimPRmiJQfGFRNTEZh1dtY0=
|
|||||||
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
|
pgregory.net/rapid v0.4.2/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
|
||||||
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=
|
||||||
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
|
||||||
xorm.io/xorm v1.4.1 h1:m7QlNd0eBGb31IV4Q/ow0Du83rtdC1CiwlvJZGvYde8=
|
xorm.io/xorm v1.4.3 h1:MwWFWzVr+/6D07qGCDhBAfABcuT0gvqY3XmTy1215BM=
|
||||||
xorm.io/xorm v1.4.1/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
xorm.io/xorm v1.4.3/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q=
|
||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"gitea.dev/modelmigration/v1_8"
|
"gitea.dev/modelmigration/v1_8"
|
||||||
"gitea.dev/modelmigration/v1_9"
|
"gitea.dev/modelmigration/v1_9"
|
||||||
"gitea.dev/modelmigration/v28"
|
"gitea.dev/modelmigration/v28"
|
||||||
|
"gitea.dev/modelmigration/v29"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
@@ -429,6 +430,10 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(353, "Add audit event table", v28.AddAuditEventTable),
|
newMigration(353, "Add audit event table", v28.AddAuditEventTable),
|
||||||
newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes),
|
newMigration(354, "Add Actions job queue indexes", v28.AddActionQueueIndexes),
|
||||||
newMigration(355, "Add AutoMerge merged_commit_id column", v28.AddAutoMergeMergedCommitID),
|
newMigration(355, "Add AutoMerge merged_commit_id column", v28.AddAutoMergeMergedCommitID),
|
||||||
|
// Gitea 28.0.0 ends at migration ID number 355 (database version 356)
|
||||||
|
// HERE: 2 migrations from 29 are added since they don't change database structure
|
||||||
|
newMigration(356, "Add index on action_run commit_sha", v29.AddActionRunCommitSHAIndex),
|
||||||
|
newMigration(357, "Normalize legacy team authorize values", v29.NormalizeLegacyTeamAuthorize),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v29
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modelmigration/migrationtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
migrationtest.MainTest(m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v29
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.dev/modelmigration/base"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddActionRunCommitSHAIndex indexes the runs lookup by commit, which the API `head_sha` filter uses.
|
||||||
|
func AddActionRunCommitSHAIndex(_ context.Context, x base.EngineMigration) error {
|
||||||
|
type ActionRun struct {
|
||||||
|
CommitSHA string `xorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := x.SyncWithOptions(xorm.SyncOptions{
|
||||||
|
IgnoreDropIndices: true,
|
||||||
|
IgnoreConstrains: true,
|
||||||
|
}, new(ActionRun))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v29
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.dev/modelmigration/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeLegacyTeamAuthorize sets leftover read/write authorize values to none.
|
||||||
|
// https://github.com/go-gitea/gitea/pull/34128 made non-admin teams use team_unit (authorize=none).
|
||||||
|
// authorize>=write now means blanket access on every unit; migrating legacy read/write
|
||||||
|
// to none preserves their existing team_unit-scoped access.
|
||||||
|
func NormalizeLegacyTeamAuthorize(_ context.Context, x base.EngineMigration) error {
|
||||||
|
// AccessModeNone=0, AccessModeRead=1, AccessModeWrite=2, AccessModeAdmin=3
|
||||||
|
_, err := x.Exec(`
|
||||||
|
UPDATE team SET authorize = 0
|
||||||
|
WHERE authorize > 0 AND authorize < 3
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM team_unit WHERE team_unit.team_id = team.id
|
||||||
|
);`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v29
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modelmigration/migrationtest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeLegacyTeamAuthorize(t *testing.T) {
|
||||||
|
type Team struct {
|
||||||
|
ID int64 `xorm:"pk"`
|
||||||
|
Authorize int
|
||||||
|
}
|
||||||
|
type TeamUnit struct {
|
||||||
|
ID int64 `xorm:"pk"`
|
||||||
|
TeamID int64 `xorm:"INDEX"`
|
||||||
|
}
|
||||||
|
|
||||||
|
x, deferrable := migrationtest.PrepareTestEnv(t, 0, new(Team), new(TeamUnit))
|
||||||
|
defer deferrable()
|
||||||
|
if x == nil || t.Failed() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := x.Insert(
|
||||||
|
&Team{ID: 1, Authorize: 4},
|
||||||
|
&Team{ID: 2, Authorize: 3},
|
||||||
|
&Team{ID: 3, Authorize: 2},
|
||||||
|
&Team{ID: 4, Authorize: 1},
|
||||||
|
&Team{ID: 5, Authorize: 0},
|
||||||
|
|
||||||
|
&TeamUnit{TeamID: 3},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, NormalizeLegacyTeamAuthorize(t.Context(), x))
|
||||||
|
|
||||||
|
get := func(id int64) int {
|
||||||
|
tBean := &Team{ID: id}
|
||||||
|
has, err := x.Get(tBean)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, has)
|
||||||
|
return tBean.Authorize
|
||||||
|
}
|
||||||
|
assert.Equal(t, 4, get(1))
|
||||||
|
assert.Equal(t, 3, get(2))
|
||||||
|
assert.Equal(t, 0, get(3)) // has team unit, reset to none
|
||||||
|
assert.Equal(t, 1, get(4)) // no team unit, kept
|
||||||
|
assert.Equal(t, 0, get(5))
|
||||||
|
}
|
||||||
@@ -93,7 +93,12 @@ const artifactKeepForever timeutil.TimeStamp = 0
|
|||||||
|
|
||||||
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
|
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
|
||||||
if requested.Has() {
|
if requested.Has() {
|
||||||
return max(requested.Value(), artifactKeepForever+1)
|
now := timeutil.TimeStampNow()
|
||||||
|
expiry := max(requested.Value(), now.Add(timeutil.Day)) // v4 clients use the runner's clock
|
||||||
|
if setting.Actions.ArtifactRetentionDays > 0 {
|
||||||
|
expiry = min(expiry, now.Add(timeutil.Day*setting.Actions.ArtifactRetentionDays))
|
||||||
|
}
|
||||||
|
return expiry
|
||||||
}
|
}
|
||||||
if setting.Actions.ArtifactRetentionDays <= 0 {
|
if setting.Actions.ArtifactRetentionDays <= 0 {
|
||||||
return artifactKeepForever
|
return artifactKeepForever
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ package actions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/modules/optional"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -25,3 +31,21 @@ func TestKeepLatestAttemptArtifacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.Equal(t, []int64{1, 4, 5}, ids)
|
assert.Equal(t, []int64{1, 4, 5}, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestArtifactExpiry(t *testing.T) {
|
||||||
|
defer timeutil.MockSet(time.Unix(1_000_000_000, 0))()
|
||||||
|
now := timeutil.TimeStampNow()
|
||||||
|
days := func(n int64) timeutil.TimeStamp { return now.Add(n * timeutil.Day) }
|
||||||
|
requested := optional.Some[timeutil.TimeStamp]
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 90)()
|
||||||
|
assert.Equal(t, days(90), artifactExpiry(optional.None[timeutil.TimeStamp]()))
|
||||||
|
assert.Equal(t, days(5), artifactExpiry(requested(days(5))))
|
||||||
|
assert.Equal(t, days(1), artifactExpiry(requested(days(-2))), "runner clock behind must not expire the artifact at once")
|
||||||
|
assert.Equal(t, days(90), artifactExpiry(requested(days(1000))), "runner must not exceed the instance retention")
|
||||||
|
|
||||||
|
setting.Actions.ArtifactRetentionDays = 0
|
||||||
|
assert.Equal(t, artifactKeepForever, artifactExpiry(optional.None[timeutil.TimeStamp]()))
|
||||||
|
assert.Equal(t, days(1000), artifactExpiry(requested(days(1000))))
|
||||||
|
assert.Equal(t, days(1), artifactExpiry(requested(now)))
|
||||||
|
}
|
||||||
@@ -35,13 +35,13 @@ type ActionRun struct {
|
|||||||
Repo *repo_model.Repository `xorm:"-"`
|
Repo *repo_model.Repository `xorm:"-"`
|
||||||
OwnerID int64 `xorm:"index"`
|
OwnerID int64 `xorm:"index"`
|
||||||
WorkflowID string `xorm:"index"` // the name of workflow file
|
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||||
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
|
Index int64 `xorm:"unique(repo_index)"` // a unique number for each run of a repository
|
||||||
TriggerUserID int64 `xorm:"index"`
|
TriggerUserID int64 `xorm:"index"`
|
||||||
TriggerUser *user_model.User `xorm:"-"`
|
TriggerUser *user_model.User `xorm:"-"`
|
||||||
ScheduleID int64
|
ScheduleID int64
|
||||||
Ref string `xorm:"index"` // the commit/tag/… that caused the run
|
Ref string `xorm:"index"` // the commit/tag/… that caused the run
|
||||||
IsRefDeleted bool `xorm:"-"`
|
IsRefDeleted bool `xorm:"-"`
|
||||||
CommitSHA string
|
CommitSHA string `xorm:"index"`
|
||||||
IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow.
|
IsForkPullRequest bool // If this is triggered by a PR from a forked repository or an untrusted user, we need to check if it is approved and limit permissions when running the workflow.
|
||||||
NeedApproval bool // may need approval if it's a fork pull request
|
NeedApproval bool // may need approval if it's a fork pull request
|
||||||
ApprovedBy int64 `xorm:"index"` // who approved
|
ApprovedBy int64 `xorm:"index"` // who approved
|
||||||
@@ -295,9 +295,8 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er
|
|||||||
return &run, nil
|
return &run, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) {
|
func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) (runs []*ActionRun, err error) {
|
||||||
var runs []*ActionRun
|
err = db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).OrderBy("id").Find(&runs)
|
||||||
err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs)
|
|
||||||
return runs, err
|
return runs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-18
@@ -25,7 +25,6 @@ import (
|
|||||||
|
|
||||||
// MaxJobNumPerRun is the maximum number of jobs in a single run.
|
// MaxJobNumPerRun is the maximum number of jobs in a single run.
|
||||||
// https://docs.github.com/en/actions/reference/limits#existing-system-limits
|
// https://docs.github.com/en/actions/reference/limits#existing-system-limits
|
||||||
// TODO: check this limit when creating jobs
|
|
||||||
const MaxJobNumPerRun = 256
|
const MaxJobNumPerRun = 256
|
||||||
|
|
||||||
// ActionRunJob represents a job of a run
|
// ActionRunJob represents a job of a run
|
||||||
@@ -100,7 +99,7 @@ type ActionRunJob struct {
|
|||||||
|
|
||||||
// IsReusableCaller marks this job as a reusable workflow caller.
|
// IsReusableCaller marks this job as a reusable workflow caller.
|
||||||
// Caller jobs do not run on a runner; their status is derived from their child jobs.
|
// Caller jobs do not run on a runner; their status is derived from their child jobs.
|
||||||
IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"`
|
IsReusableCaller bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||||
// IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete.
|
// IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete.
|
||||||
// For a reusable workflow caller, true means children rows exist and CallPayload is populated.
|
// For a reusable workflow caller, true means children rows exist and CallPayload is populated.
|
||||||
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
|
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||||
@@ -440,14 +439,6 @@ func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*
|
|||||||
return out
|
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) {
|
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
||||||
e := db.GetEngine(ctx)
|
e := db.GetEngine(ctx)
|
||||||
|
|
||||||
@@ -498,7 +489,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
|||||||
// Only bump when waiting work actually remains for this repo, otherwise
|
// Only bump when waiting work actually remains for this repo, otherwise
|
||||||
// every job completion would needlessly bump the global version and wake
|
// every job completion would needlessly bump the global version and wake
|
||||||
// every idle runner instance-wide for nothing.
|
// every idle runner instance-wide for nothing.
|
||||||
hasWaiting, err := hasWaitingJobsToPick(ctx, job.RepoID)
|
hasWaiting, err := JobQueueOptions{RepoID: job.RepoID, Status: StatusWaiting}.session(ctx).Exist(new(ActionRunJob))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -853,25 +844,34 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
|
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
|
||||||
// so each child must reach its final state before its parent caller is re-aggregated.
|
// so each child must be cancelled before its parent caller is re-read.
|
||||||
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
|
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
|
||||||
descendants := CollectAllDescendantJobs(caller, attemptJobs)
|
descendants := CollectAllDescendantJobs(caller, attemptJobs)
|
||||||
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
||||||
|
callersWithChildren := make(container.Set[int64])
|
||||||
|
for _, d := range descendants {
|
||||||
|
callersWithChildren.Add(d.ParentJobID)
|
||||||
|
}
|
||||||
|
|
||||||
for _, c := range descendants {
|
for _, job := range append(descendants, caller) {
|
||||||
cancelled, err := cancelOneJob(ctx, c, force)
|
if !callersWithChildren.Contains(job.ID) {
|
||||||
|
cancelled, err := cancelOneJob(ctx, job, force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cancelledJobs, err
|
return cancelledJobs, err
|
||||||
}
|
}
|
||||||
if cancelled != nil {
|
if cancelled != nil {
|
||||||
cancelledJobs = append(cancelledJobs, cancelled)
|
cancelledJobs = append(cancelledJobs, cancelled)
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
// the job is a caller and its children's cascade already re-aggregated it
|
||||||
if c, err := cancelOneJob(ctx, caller, force); err != nil {
|
reloaded, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
|
||||||
|
if err != nil {
|
||||||
return cancelledJobs, err
|
return cancelledJobs, err
|
||||||
} else if c != nil {
|
}
|
||||||
cancelledJobs = append(cancelledJobs, c)
|
if reloaded.Status != job.Status {
|
||||||
|
cancelledJobs = append(cancelledJobs, reloaded)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return cancelledJobs, nil
|
return cancelledJobs, nil
|
||||||
}
|
}
|
||||||
@@ -27,13 +27,12 @@ func (opts JobQueueOptions) session(ctx context.Context) *xorm.Session {
|
|||||||
sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
|
sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
|
||||||
}
|
}
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID > 0 {
|
||||||
sess = sess.Join("INNER", "repository", "repository.id = `action_run_job`.repo_id AND repository.owner_id = ?", opts.OwnerID)
|
sess = sess.And(builder.In("`action_run_job`.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"owner_id": opts.OwnerID})))
|
||||||
}
|
}
|
||||||
return sess.And(opts.statusCond())
|
return sess.And(opts.statusCond())
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// keep in sync with CreateTaskForRunner
|
|
||||||
queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0}
|
queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0}
|
||||||
// a cancelling job still occupies its runner
|
// a cancelling job still occupies its runner
|
||||||
runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling)
|
runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
@@ -397,3 +398,72 @@ func TestForceCancelJobs(t *testing.T) {
|
|||||||
assert.Equal(t, StatusCancelled, callerAfter.Status)
|
assert.Equal(t, StatusCancelled, callerAfter.Status)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCancelJobs_CallerWaitsForCancellingChild(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
run := &ActionRun{
|
||||||
|
Title: "caller-cancelling-child",
|
||||||
|
RepoID: 4,
|
||||||
|
Index: 9811,
|
||||||
|
OwnerID: 1,
|
||||||
|
WorkflowID: "test.yaml",
|
||||||
|
TriggerUserID: 1,
|
||||||
|
Ref: "refs/heads/master",
|
||||||
|
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||||
|
Event: "push",
|
||||||
|
TriggerEvent: "push",
|
||||||
|
EventPayload: "{}",
|
||||||
|
Status: StatusRunning,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Insert(ctx, run))
|
||||||
|
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusRunning}
|
||||||
|
require.NoError(t, db.Insert(ctx, attempt))
|
||||||
|
run.LatestAttemptID = attempt.ID
|
||||||
|
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||||
|
|
||||||
|
newJob := func(name string, parentID int64, isCaller bool) *ActionRunJob {
|
||||||
|
job := &ActionRunJob{
|
||||||
|
RunID: run.ID,
|
||||||
|
RunAttemptID: attempt.ID,
|
||||||
|
RepoID: run.RepoID,
|
||||||
|
OwnerID: run.OwnerID,
|
||||||
|
CommitSHA: run.CommitSHA,
|
||||||
|
Name: name,
|
||||||
|
JobID: name,
|
||||||
|
Attempt: 1,
|
||||||
|
Status: StatusRunning,
|
||||||
|
ParentJobID: parentID,
|
||||||
|
IsReusableCaller: isCaller,
|
||||||
|
IsExpanded: isCaller,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Insert(ctx, job))
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
outer := newJob("outer", 0, true)
|
||||||
|
inner := newJob("inner", outer.ID, true)
|
||||||
|
child := newJob("child", inner.ID, false)
|
||||||
|
|
||||||
|
runner := &ActionRunner{UUID: "caller-cancelling-child", Name: "caller-cancelling-child", HasCancellingSupport: true}
|
||||||
|
require.NoError(t, db.Insert(ctx, runner))
|
||||||
|
task := &ActionTask{JobID: child.ID, Attempt: 1, RunnerID: runner.ID, Status: StatusRunning, Started: timeutil.TimeStampNow(), RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA}
|
||||||
|
require.NoError(t, db.Insert(ctx, task))
|
||||||
|
child.TaskID = task.ID
|
||||||
|
_, err := UpdateRunJob(ctx, child, nil, "task_id")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cancelled, err := CancelJobs(ctx, []*ActionRunJob{outer}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, cancelled, 3)
|
||||||
|
for _, job := range []*ActionRunJob{outer, inner, child} {
|
||||||
|
assert.Equal(t, StatusCancelling, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = UpdateTaskByState(ctx, runner.ID, &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_CANCELLED})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, job := range []*ActionRunJob{outer, inner, child} {
|
||||||
|
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
|
||||||
|
}
|
||||||
|
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID}).Status)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
webhook_module "gitea.dev/modules/webhook"
|
webhook_module "gitea.dev/modules/webhook"
|
||||||
@@ -110,7 +111,7 @@ func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) ([
|
|||||||
jobs, err := CancelPreviousJobs(
|
jobs, err := CancelPreviousJobs(
|
||||||
ctx,
|
ctx,
|
||||||
repo.ID,
|
repo.ID,
|
||||||
repo.DefaultBranch,
|
git.RefNameFromBranch(repo.DefaultBranch).String(),
|
||||||
"",
|
"",
|
||||||
webhook_module.HookEventSchedule,
|
webhook_module.HookEventSchedule,
|
||||||
)
|
)
|
||||||
|
|||||||
+37
-38
@@ -236,13 +236,13 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro
|
|||||||
|
|
||||||
func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
||||||
if step.Name != "" {
|
if step.Name != "" {
|
||||||
name = string(step.Name) // the step has an explicit name
|
name = step.Name // the step has an explicit name
|
||||||
} else {
|
} else {
|
||||||
// for unnamed step, its "String()" method tries to get a display name by its "name", "uses",
|
// for unnamed step, its "String()" method tries to get a display name by its "name", "uses",
|
||||||
// "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display
|
// "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display
|
||||||
// for multi-line "run" scripts, only use the first line to match GitHub's behavior
|
// for multi-line "run" scripts, only use the first line to match GitHub's behavior
|
||||||
// https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58
|
// https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58
|
||||||
runStr, _, _ := strings.Cut(strings.TrimSpace(string(step.Run)), "\n")
|
runStr, _, _ := strings.Cut(strings.TrimSpace(step.Run), "\n")
|
||||||
name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String())
|
name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String())
|
||||||
}
|
}
|
||||||
return util.EllipsisDisplayString(name, limit) // database column has a length limit
|
return util.EllipsisDisplayString(name, limit) // database column has a length limit
|
||||||
@@ -261,21 +261,10 @@ var pickTaskBatchSize = 100
|
|||||||
// atomically claims it. It iterates through all matching jobs so that a
|
// atomically claims it. It iterates through all matching jobs so that a
|
||||||
// concurrent claim by another runner (which would lose the optimistic lock on
|
// concurrent claim by another runner (which would lose the optimistic lock on
|
||||||
// job #1) does not leave the remaining jobs permanently unassigned.
|
// job #1) does not leave the remaining jobs permanently unassigned.
|
||||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
|
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (task *ActionTask, cancelledJobs []*ActionRunJob, err error) {
|
||||||
if db.InTransaction(ctx) {
|
if db.InTransaction(ctx) {
|
||||||
return nil, false, errors.New("CreateTaskForRunner must not be called within a database transaction")
|
return nil, nil, errors.New("CreateTaskForRunner must not be called within a database transaction")
|
||||||
}
|
}
|
||||||
e := db.GetEngine(ctx)
|
|
||||||
|
|
||||||
jobCond := builder.NewCond()
|
|
||||||
if runner.RepoID != 0 {
|
|
||||||
jobCond = builder.Eq{"repo_id": runner.RepoID}
|
|
||||||
} else if runner.OwnerID != 0 {
|
|
||||||
jobCond = builder.In("repo_id", builder.Select("`repository`.id").From("repository").
|
|
||||||
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
|
|
||||||
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
|
|
||||||
}
|
|
||||||
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
|
|
||||||
|
|
||||||
// TODO: a more efficient way to filter labels
|
// TODO: a more efficient way to filter labels
|
||||||
log.Trace("runner labels: %v", runner.AgentLabels)
|
log.Trace("runner labels: %v", runner.AgentLabels)
|
||||||
@@ -286,48 +275,52 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
|||||||
var cursorUpdated timeutil.TimeStamp
|
var cursorUpdated timeutil.TimeStamp
|
||||||
var cursorID int64
|
var cursorID int64
|
||||||
for {
|
for {
|
||||||
cond := baseCond
|
sess := JobQueueOptions{RepoID: runner.RepoID, OwnerID: runner.OwnerID, Status: StatusWaiting}.session(ctx)
|
||||||
if cursorID > 0 {
|
if cursorID > 0 {
|
||||||
cond = cond.And(builder.Or(
|
sess.And(builder.Or(
|
||||||
builder.Gt{"updated": cursorUpdated},
|
builder.Gt{"updated": cursorUpdated},
|
||||||
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
var jobs []*ActionRunJob
|
var jobs []*ActionRunJob
|
||||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
if err := sess.Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||||
return nil, false, err
|
return nil, cancelledJobs, err
|
||||||
|
}
|
||||||
|
// A short page means no waiting jobs remain beyond it.
|
||||||
|
isLastPage := len(jobs) < pickTaskBatchSize
|
||||||
|
if !isLastPage {
|
||||||
|
last := jobs[len(jobs)-1] // read before a lost claim bumps Updated
|
||||||
|
cursorUpdated, cursorID = last.Updated, last.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range jobs {
|
for _, v := range jobs {
|
||||||
if !runner.CanMatchLabels(v.RunsOn) {
|
if !runner.CanMatchLabels(v.RunsOn) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
task, cancelled, err := claimJobForRunner(ctx, runner, v)
|
||||||
if err != nil {
|
if cancelled {
|
||||||
return nil, false, err
|
cancelledJobs = append(cancelledJobs, v)
|
||||||
}
|
}
|
||||||
if ok {
|
if err != nil || task != nil {
|
||||||
return task, true, nil
|
return task, cancelledJobs, err
|
||||||
}
|
}
|
||||||
// Another runner claimed this job concurrently; try the next one.
|
// Another runner claimed this job concurrently, or it was cancelled. Try the next one.
|
||||||
}
|
}
|
||||||
|
|
||||||
// A short page means no waiting jobs remain beyond it.
|
if isLastPage {
|
||||||
if len(jobs) < pickTaskBatchSize {
|
return nil, cancelledJobs, nil
|
||||||
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
|
// claimJobForRunner attempts to atomically claim job for runner inside its own
|
||||||
// transaction. Returns (task, true, nil) on success, or (nil, false, nil) when
|
// transaction. Returns the task on success, cancelled when the job's repository
|
||||||
// another runner wins the optimistic-lock race (the caller should try the next
|
// has Actions disabled, or neither when another runner wins the optimistic-lock
|
||||||
// candidate job).
|
// race (the caller should try the next candidate job).
|
||||||
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
|
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
|
||||||
var resultTask *ActionTask
|
var resultTask *ActionTask
|
||||||
|
var cancelled bool
|
||||||
|
|
||||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
e := db.GetEngine(ctx)
|
e := db.GetEngine(ctx)
|
||||||
@@ -335,6 +328,15 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
|
|||||||
if err := job.LoadAttributes(ctx); err != nil {
|
if err := job.LoadAttributes(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := job.Run.Repo.LoadUnits(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !job.Run.Repo.UnitEnabled(ctx, unit.TypeActions) {
|
||||||
|
job.Status, job.Stopped = StatusCancelled, timeutil.TimeStampNow()
|
||||||
|
affected, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0, "status": StatusWaiting}, "status", "stopped")
|
||||||
|
cancelled = err == nil && affected == 1
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
now := timeutil.TimeStampNow()
|
now := timeutil.TimeStampNow()
|
||||||
job.Started = now
|
job.Started = now
|
||||||
@@ -400,13 +402,10 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if errors.Is(err, errJobAlreadyClaimed) {
|
if err != nil && !errors.Is(err, errJobAlreadyClaimed) {
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
return resultTask, true, nil
|
return resultTask, cancelled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes
|
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/models/unit"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
"gitea.dev/modules/actions/jobparser"
|
"gitea.dev/modules/actions/jobparser"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
@@ -94,14 +95,14 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "very long name truncated",
|
name: "very long name truncated",
|
||||||
jobStep: &jobparser.Step{
|
jobStep: &jobparser.Step{
|
||||||
Name: jobparser.BlockSafeString(strings.Repeat("a", 300)),
|
Name: strings.Repeat("a", 300),
|
||||||
},
|
},
|
||||||
expected: strings.Repeat("a", 252) + "…",
|
expected: strings.Repeat("a", 252) + "…",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "very long run truncated",
|
name: "very long run truncated",
|
||||||
jobStep: &jobparser.Step{
|
jobStep: &jobparser.Step{
|
||||||
Run: jobparser.BlockSafeString(strings.Repeat("a", 300)),
|
Run: strings.Repeat("a", 300),
|
||||||
},
|
},
|
||||||
expected: "Run " + strings.Repeat("a", 248) + "…",
|
expected: "Run " + strings.Repeat("a", 248) + "…",
|
||||||
},
|
},
|
||||||
@@ -258,6 +259,11 @@ func TestReleaseTaskForRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Insert(t.Context(), run))
|
require.NoError(t, db.Insert(t.Context(), run))
|
||||||
|
|
||||||
|
disabledRun := &ActionRun{RepoID: 2, TriggerUserID: 2}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), disabledRun))
|
||||||
|
disabledJob := &ActionRunJob{RunID: disabledRun.ID, RepoID: 2, Status: StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), disabledJob))
|
||||||
|
|
||||||
job := &ActionRunJob{
|
job := &ActionRunJob{
|
||||||
RunID: run.ID,
|
RunID: run.ID,
|
||||||
RepoID: run.RepoID,
|
RepoID: run.RepoID,
|
||||||
@@ -279,11 +285,13 @@ func TestReleaseTaskForRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
runner.GenerateAndFillToken()
|
runner.GenerateAndFillToken()
|
||||||
require.NoError(t, db.Insert(t.Context(), runner))
|
require.NoError(t, db.Insert(t.Context(), runner))
|
||||||
|
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: run.RepoID, Type: unit.TypeActions}))
|
||||||
|
|
||||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
task, cancelledJobs, err := CreateTaskForRunner(t.Context(), runner)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, ok)
|
|
||||||
require.NotNil(t, task)
|
require.NotNil(t, task)
|
||||||
|
require.Len(t, cancelledJobs, 1)
|
||||||
|
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: disabledJob.ID}).Status)
|
||||||
|
|
||||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||||
require.Equal(t, StatusRunning, claimed.Status)
|
require.Equal(t, StatusRunning, claimed.Status)
|
||||||
@@ -324,6 +332,11 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Insert(t.Context(), run))
|
require.NoError(t, db.Insert(t.Context(), run))
|
||||||
|
|
||||||
|
disabledRun := &ActionRun{RepoID: 2, TriggerUserID: 2}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), disabledRun))
|
||||||
|
disabledJob := &ActionRunJob{RunID: disabledRun.ID, RepoID: 2, Status: StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), disabledJob))
|
||||||
|
|
||||||
// Five waiting jobs the runner cannot run, then one it can.
|
// 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.
|
// With a page size of 2 the matching job only appears on the third page.
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
@@ -362,11 +375,13 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
runner.GenerateAndFillToken()
|
runner.GenerateAndFillToken()
|
||||||
require.NoError(t, db.Insert(t.Context(), runner))
|
require.NoError(t, db.Insert(t.Context(), runner))
|
||||||
|
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: run.RepoID, Type: unit.TypeActions}))
|
||||||
|
|
||||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
task, cancelledJobs, err := CreateTaskForRunner(t.Context(), runner)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, ok)
|
|
||||||
require.NotNil(t, task)
|
require.NotNil(t, task)
|
||||||
|
require.Len(t, cancelledJobs, 1)
|
||||||
|
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: disabledJob.ID}).Status)
|
||||||
|
|
||||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
|
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
|
||||||
assert.Equal(t, StatusRunning, claimed.Status)
|
assert.Equal(t, StatusRunning, claimed.Status)
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (opts FindGPGKeyOptions) ToConds() builder.Cond {
|
|||||||
cond = cond.And(builder.Eq{"primary_key_id": ""})
|
cond = cond.And(builder.Eq{"primary_key_id": ""})
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID != 0 {
|
||||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||||
}
|
}
|
||||||
if opts.KeyID != "" {
|
if opts.KeyID != "" {
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func SearchPrincipalKey(ctx context.Context, principalKey string) (*PublicKey, e
|
|||||||
// In the future, the existing principal keys should be migrated to use the fingerprint ("principal:{name}") instead of the content
|
// In the future, the existing principal keys should be migrated to use the fingerprint ("principal:{name}") instead of the content
|
||||||
key := new(PublicKey)
|
key := new(PublicKey)
|
||||||
has, err := db.GetEngine(ctx).
|
has, err := db.GetEngine(ctx).
|
||||||
Where("content = ?", principalKey).
|
Where("content = ? AND type = ?", principalKey, KeyTypePrincipal).
|
||||||
Get(key)
|
Get(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -224,7 +224,7 @@ func (opts FindPublicKeyOptions) ToOrders() string {
|
|||||||
|
|
||||||
func (opts FindPublicKeyOptions) ToConds() builder.Cond {
|
func (opts FindPublicKeyOptions) ToConds() builder.Cond {
|
||||||
cond := builder.NewCond()
|
cond := builder.NewCond()
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID != 0 {
|
||||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||||
}
|
}
|
||||||
if opts.Fingerprint != "" {
|
if opts.Fingerprint != "" {
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
|
||||||
"github.com/42wim/sshsig"
|
"github.com/42wim/sshsig"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_SSHParsePublicKey(t *testing.T) {
|
func Test_SSHParsePublicKey(t *testing.T) {
|
||||||
@@ -481,6 +483,20 @@ func TestPublicKeysAreExternallyManaged(t *testing.T) {
|
|||||||
assert.False(t, externals[0])
|
assert.False(t, externals[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchPrincipalKeyIgnoresOtherKeyTypes(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
userKey := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1})
|
||||||
|
|
||||||
|
_, err := SearchPrincipalKey(t.Context(), userKey.Content)
|
||||||
|
assert.True(t, IsErrKeyNotExist(err))
|
||||||
|
|
||||||
|
principal := &PublicKey{OwnerID: 1, Name: userKey.Content, Content: userKey.Content, Type: KeyTypePrincipal}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), principal))
|
||||||
|
key, err := SearchPrincipalKey(t.Context(), userKey.Content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, principal.ID, key.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations.
|
// TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations.
|
||||||
func TestCheckPublicKeyStringOversized(t *testing.T) {
|
func TestCheckPublicKeyStringOversized(t *testing.T) {
|
||||||
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
|
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
|
|||||||
return at, nil
|
return at, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error {
|
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken, oldHash string) (bool, error) {
|
||||||
_, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
|
affected, err := db.GetEngine(ctx).ID(t.ID).Where("token_hash = ?", oldHash).
|
||||||
return err
|
Cols("token_hash", "expires_unix").Update(t)
|
||||||
|
return affected == 1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteAuthTokenByID(ctx context.Context, id string) error {
|
func DeleteAuthTokenByID(ctx context.Context, id string) error {
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
|
|||||||
DisplayName: "Gitea App",
|
DisplayName: "Gitea App",
|
||||||
RedirectURIs: []string{"com.gitea.app://oauth/callback"},
|
RedirectURIs: []string{"com.gitea.app://oauth/callback"},
|
||||||
}
|
}
|
||||||
|
m["75a9ec3e-42b5-40cb-9144-9ea96ecd56a6"] = &BuiltinOAuth2Application{
|
||||||
|
ConfigName: "gitea-vscode",
|
||||||
|
DisplayName: "gitea-vscode",
|
||||||
|
RedirectURIs: []string{
|
||||||
|
"http://127.0.0.1",
|
||||||
|
"https://127.0.0.1",
|
||||||
|
"vscode://gitea.gitea-for-vscode/auth/callback",
|
||||||
|
"vscode-insiders://gitea.gitea-for-vscode/auth/callback",
|
||||||
|
},
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,6 +574,12 @@ func (grant *OAuth2Grant) SetNonce(ctx context.Context, nonce string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UpdateGrantScope(ctx context.Context, grant *OAuth2Grant, newScope string) error {
|
||||||
|
grant.Scope = newScope
|
||||||
|
_, err := db.GetEngine(ctx).ID(grant.ID).Cols("scope").Update(grant)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// GetOAuth2GrantByID returns the grant with the given ID
|
// GetOAuth2GrantByID returns the grant with the given ID
|
||||||
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
|
func GetOAuth2GrantByID(ctx context.Context, id int64) (grant *OAuth2Grant, err error) {
|
||||||
grant = new(OAuth2Grant)
|
grant = new(OAuth2Grant)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
@@ -59,6 +61,11 @@ func InitEngine(ctx context.Context) error {
|
|||||||
xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
|
xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
|
||||||
xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
|
xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
|
||||||
|
|
||||||
|
if setting.Database.Type.IsMySQL() {
|
||||||
|
// like PostgreSQL and MSSQL, avoids MariaDB snapshot isolation errors
|
||||||
|
xe.SetDefaultTxOptions(&sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||||
|
}
|
||||||
|
|
||||||
if setting.Database.SlowQueryThreshold > 0 {
|
if setting.Database.SlowQueryThreshold > 0 {
|
||||||
xe.AddHook(&EngineHook{
|
xe.AddHook(&EngineHook{
|
||||||
Threshold: setting.Database.SlowQueryThreshold,
|
Threshold: setting.Database.SlowQueryThreshold,
|
||||||
@@ -103,6 +110,10 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
|
|||||||
|
|
||||||
preprocessDatabaseCollation(xormEngine)
|
preprocessDatabaseCollation(xormEngine)
|
||||||
|
|
||||||
|
if setting.Database.Type.IsMSSQL() {
|
||||||
|
enableMSSQLReadCommittedSnapshot(ctx, xormEngine)
|
||||||
|
}
|
||||||
|
|
||||||
// We have to run migrateFunc here in case the user is re-running installation on a previously created DB.
|
// We have to run migrateFunc here in case the user is re-running installation on a previously created DB.
|
||||||
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
|
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
|
||||||
//
|
//
|
||||||
@@ -125,3 +136,12 @@ func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Conte
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// enableMSSQLReadCommittedSnapshot stops MSSQL reads waiting on writers, like PostgreSQL and MySQL
|
||||||
|
func enableMSSQLReadCommittedSnapshot(ctx context.Context, engine EngineMigration) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // ALTER waits for all other connections to close
|
||||||
|
defer cancel()
|
||||||
|
if _, err := engine.Context(ctx).Exec("IF (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE database_id = DB_ID()) = 0 ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON"); err != nil {
|
||||||
|
log.Error("Unable to set READ_COMMITTED_SNAPSHOT=ON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-39
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -35,11 +36,11 @@ import (
|
|||||||
// CommitStatus holds a single Status of a single Commit
|
// CommitStatus holds a single Status of a single Commit
|
||||||
type CommitStatus struct {
|
type CommitStatus struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"UNIQUE(repo_sha_index)"`
|
||||||
|
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"`
|
||||||
Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
|
Index int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
|
||||||
RepoID int64 `xorm:"INDEX UNIQUE(repo_sha_index)"`
|
|
||||||
Repo *repo_model.Repository `xorm:"-"`
|
Repo *repo_model.Repository `xorm:"-"`
|
||||||
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
|
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
|
||||||
SHA string `xorm:"VARCHAR(64) NOT NULL INDEX UNIQUE(repo_sha_index)"`
|
|
||||||
|
|
||||||
// TargetURL points to the commit status page reported by a CI system
|
// TargetURL points to the commit status page reported by a CI system
|
||||||
// If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}"
|
// If Gitea Actions is used, it is a relative link like "{RepoLink}/actions/runs/{RunID}/jobs{JobID}"
|
||||||
@@ -181,7 +182,7 @@ func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (in
|
|||||||
|
|
||||||
func (status *CommitStatus) loadRepository(ctx context.Context) (err error) {
|
func (status *CommitStatus) loadRepository(ctx context.Context) (err error) {
|
||||||
if status.Repo == nil {
|
if status.Repo == nil {
|
||||||
status.Repo, err = repo_model.GetRepositoryByID(ctx, status.RepoID)
|
status.Repo, err = cache.GetWithContextCache(ctx, cachegroup.Repository, status.RepoID, repo_model.GetRepositoryByID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err)
|
return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err)
|
||||||
}
|
}
|
||||||
@@ -229,12 +230,10 @@ func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context)
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.Repo == nil {
|
|
||||||
if err := status.loadRepository(ctx); err != nil {
|
if err := status.loadRepository(ctx); err != nil {
|
||||||
log.Error("loadRepository: %v", err)
|
log.Error("loadRepository: %v", err)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
prefix := status.Repo.Link() + "/actions"
|
prefix := status.Repo.Link() + "/actions"
|
||||||
return strings.CutPrefix(status.TargetURL, prefix)
|
return strings.CutPrefix(status.TargetURL, prefix)
|
||||||
@@ -311,17 +310,17 @@ func (opts *CommitStatusOptions) ToConds() builder.Cond {
|
|||||||
func (opts *CommitStatusOptions) ToOrders() string {
|
func (opts *CommitStatusOptions) ToOrders() string {
|
||||||
switch opts.SortType {
|
switch opts.SortType {
|
||||||
case "oldest":
|
case "oldest":
|
||||||
return "created_unix ASC"
|
return "created_unix ASC, `index` ASC"
|
||||||
case "recentupdate":
|
case "recentupdate":
|
||||||
return "updated_unix DESC"
|
return "updated_unix DESC, `index` DESC"
|
||||||
case "leastupdate":
|
case "leastupdate":
|
||||||
return "updated_unix ASC"
|
return "updated_unix ASC, `index` ASC"
|
||||||
case "leastindex":
|
case "leastindex":
|
||||||
return "`index` DESC"
|
return "`index` DESC"
|
||||||
case "highestindex":
|
case "highestindex":
|
||||||
return "`index` ASC"
|
return "`index` ASC"
|
||||||
default:
|
default:
|
||||||
return "created_unix DESC"
|
return "created_unix DESC, `index` DESC" // timestamps have 1s resolution, `index` keeps paging stable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,46 +419,48 @@ func GetLatestCommitStatusForPairs(ctx context.Context, repoSHAs []RepoSHA) (map
|
|||||||
return repoStatuses, nil
|
return repoStatuses, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLatestCommitStatusForRepoCommitIDs returns all statuses with a unique context for a given list of repo-sha pairs
|
// GetLatestCommitStatusForRepoCommitIDs returns the commit statuses with a unique context for a given list of repo-sha pairs
|
||||||
func GetLatestCommitStatusForRepoCommitIDs(ctx context.Context, repoID int64, commitIDs []string) (map[string][]*CommitStatus, error) {
|
// If the provided commit IDs are too many, only the first part and the last part of the commit IDs will be queried.
|
||||||
type result struct {
|
func GetLatestCommitStatusForRepoCommitIDs(ctx context.Context, repoID int64, allCommitIDs []string) (map[string][]*CommitStatus, error) {
|
||||||
|
const maxCommitIDs = 500
|
||||||
|
const maxBatchSize = 200
|
||||||
|
queryCommitIDs := allCommitIDs
|
||||||
|
if len(allCommitIDs) > maxCommitIDs {
|
||||||
|
// The commit IDs are usually from "commits list" or "compare" page (create a PR or compare commits), nobody can read so many commits at once.
|
||||||
|
// The commit IDs are usually sorted by time, so we can take the first half and the last half of the commit IDs to get the latest statuses.
|
||||||
|
log.Warn("GetLatestCommitStatusForRepoCommitIDs: too many commit IDs (%d) for repo %d, truncating to %d", len(allCommitIDs), repoID, maxCommitIDs)
|
||||||
|
queryCommitIDs = allCommitIDs[:maxCommitIDs/2]
|
||||||
|
queryCommitIDs = append(queryCommitIDs, allCommitIDs[len(allCommitIDs)-maxCommitIDs/2:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSql := func() db.Session {
|
||||||
|
return db.GetEngine(ctx).Table(&CommitStatus{}).Where("repo_id = ?", repoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type shaMaxIndexResult struct {
|
||||||
Index int64
|
Index int64
|
||||||
SHA string
|
SHA string
|
||||||
}
|
}
|
||||||
|
shaMaxIndexResults := make([]*shaMaxIndexResult, 0, len(allCommitIDs))
|
||||||
getBase := func() db.Session {
|
err := baseSql().And(builder.In("sha", queryCommitIDs)).
|
||||||
return db.GetEngine(ctx).Table(&CommitStatus{}).Where("repo_id = ?", repoID)
|
|
||||||
}
|
|
||||||
results := make([]result, 0, len(commitIDs))
|
|
||||||
|
|
||||||
conds := make([]builder.Cond, 0, len(commitIDs))
|
|
||||||
for _, sha := range commitIDs {
|
|
||||||
conds = append(conds, builder.Eq{"sha": sha})
|
|
||||||
}
|
|
||||||
sess := getBase().And(builder.Or(conds...)).
|
|
||||||
Select("max(`index`) as `index`, sha").
|
Select("max(`index`) as `index`, sha").
|
||||||
GroupBy("context_hash, sha").OrderBy("max( `index` ) desc")
|
GroupBy("context_hash, sha").
|
||||||
|
Find(&shaMaxIndexResults)
|
||||||
err := sess.Find(&results)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
repoStatuses := make(map[string][]*CommitStatus)
|
repoStatuses := make(map[string][]*CommitStatus)
|
||||||
|
for chunk := range slices.Chunk(shaMaxIndexResults, maxBatchSize) {
|
||||||
if len(results) > 0 {
|
statuses := make([]*CommitStatus, 0, len(chunk))
|
||||||
statuses := make([]*CommitStatus, 0, len(results))
|
condIndexSha := make([]builder.Cond, 0, len(chunk))
|
||||||
|
for _, res := range chunk {
|
||||||
conds = make([]builder.Cond, 0, len(results))
|
condIndexSha = append(condIndexSha, builder.Eq{"`index`": res.Index, "sha": res.SHA})
|
||||||
for _, result := range results {
|
|
||||||
conds = append(conds, builder.Eq{"`index`": result.Index, "sha": result.SHA})
|
|
||||||
}
|
}
|
||||||
err = getBase().And(builder.Or(conds...)).Find(&statuses)
|
err = baseSql().And(builder.Or(condIndexSha...)).Find(&statuses)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group the statuses by commit
|
|
||||||
for _, status := range statuses {
|
for _, status := range statuses {
|
||||||
repoStatuses[status.SHA] = append(repoStatuses[status.SHA], status)
|
repoStatuses[status.SHA] = append(repoStatuses[status.SHA], status)
|
||||||
}
|
}
|
||||||
@@ -569,11 +570,12 @@ func SignCommitsApplyDoerPermission(ctx context.Context, doer *user_model.User,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func statusRepoCanReadActions(ctx context.Context, doer *user_model.User, status *CommitStatus) bool {
|
func statusRepoCanReadActions(ctx context.Context, doer *user_model.User, status *CommitStatus) bool {
|
||||||
perm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(status.RepoID, doer),
|
|
||||||
func(ctx context.Context, _ string) (access_model.Permission, error) { // only runs on a cache miss
|
|
||||||
if err := status.loadRepository(ctx); err != nil {
|
if err := status.loadRepository(ctx); err != nil {
|
||||||
return access_model.Permission{}, err
|
log.Error("loadRepository: %v", err)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
perm, err := cache.GetWithContextCache(ctx, cachegroup.RepoUserPermission, access_model.RepoUserPermissionCacheKey(status.RepoID, doer),
|
||||||
|
func(ctx context.Context, _key string) (access_model.Permission, error) {
|
||||||
return access_model.GetDoerRepoPermission(ctx, status.Repo, doer)
|
return access_model.GetDoerRepoPermission(ctx, status.Repo, doer)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package git_test
|
package git_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/cache"
|
||||||
"gitea.dev/modules/commitstatus"
|
"gitea.dev/modules/commitstatus"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
|
|
||||||
@@ -22,9 +24,9 @@ func TestGetCommitStatuses(t *testing.T) {
|
|||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
|
||||||
sha1 := "1234123412341234123412341234123412341234" // the mocked commit ID in test fixtures
|
sha1 := "1234123412341234123412341234123412341234" // the mocked commit ID in test fixtures
|
||||||
|
|
||||||
|
t.Run("CommitStatusOptions", func(t *testing.T) {
|
||||||
statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
||||||
ListOptions: db.ListOptions{Page: 1, PageSize: 50},
|
ListOptions: db.ListOptions{Page: 1, PageSize: 50},
|
||||||
RepoID: repo1.ID,
|
RepoID: repo1.ID,
|
||||||
@@ -32,28 +34,15 @@ func TestGetCommitStatuses(t *testing.T) {
|
|||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 5, int(maxResults))
|
assert.Equal(t, 5, int(maxResults))
|
||||||
assert.Len(t, statuses, 5)
|
var indexes []int64
|
||||||
|
for _, status := range statuses {
|
||||||
assert.Equal(t, "ci/awesomeness", statuses[0].Context)
|
indexes = append(indexes, status.Index)
|
||||||
assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State)
|
}
|
||||||
|
assert.Equal(t, []int64{5, 4, 3, 2, 1}, indexes)
|
||||||
|
assert.Equal(t, "deploy/awesomeness", statuses[0].Context)
|
||||||
|
assert.Equal(t, commitstatus.CommitStatusError, statuses[0].State)
|
||||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
|
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[0].APIURL(t.Context()))
|
||||||
|
|
||||||
assert.Equal(t, "cov/awesomeness", statuses[1].Context)
|
|
||||||
assert.Equal(t, commitstatus.CommitStatusWarning, statuses[1].State)
|
|
||||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[1].APIURL(t.Context()))
|
|
||||||
|
|
||||||
assert.Equal(t, "cov/awesomeness", statuses[2].Context)
|
|
||||||
assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State)
|
|
||||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[2].APIURL(t.Context()))
|
|
||||||
|
|
||||||
assert.Equal(t, "ci/awesomeness", statuses[3].Context)
|
|
||||||
assert.Equal(t, commitstatus.CommitStatusFailure, statuses[3].State)
|
|
||||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[3].APIURL(t.Context()))
|
|
||||||
|
|
||||||
assert.Equal(t, "deploy/awesomeness", statuses[4].Context)
|
|
||||||
assert.Equal(t, commitstatus.CommitStatusError, statuses[4].State)
|
|
||||||
assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/statuses/1234123412341234123412341234123412341234", statuses[4].APIURL(t.Context()))
|
|
||||||
|
|
||||||
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
statuses, maxResults, err = db.FindAndCount[git_model.CommitStatus](t.Context(), &git_model.CommitStatusOptions{
|
||||||
ListOptions: db.ListOptions{Page: 2, PageSize: 50},
|
ListOptions: db.ListOptions{Page: 2, PageSize: 50},
|
||||||
RepoID: repo1.ID,
|
RepoID: repo1.ID,
|
||||||
@@ -62,6 +51,44 @@ func TestGetCommitStatuses(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 5, int(maxResults))
|
assert.Equal(t, 5, int(maxResults))
|
||||||
assert.Empty(t, statuses)
|
assert.Empty(t, statuses)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetCountLatestCommitStatus", func(t *testing.T) {
|
||||||
|
commitStatuses, err := git_model.GetLatestCommitStatus(t.Context(), repo1.ID, sha1, db.ListOptions{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 2,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, commitStatuses, 2)
|
||||||
|
assert.Equal(t, commitstatus.CommitStatusFailure, commitStatuses[0].State)
|
||||||
|
assert.Equal(t, "ci/awesomeness", commitStatuses[0].Context)
|
||||||
|
assert.Equal(t, commitstatus.CommitStatusError, commitStatuses[1].State)
|
||||||
|
assert.Equal(t, "deploy/awesomeness", commitStatuses[1].Context)
|
||||||
|
|
||||||
|
count, err := git_model.CountLatestCommitStatus(t.Context(), repo1.ID, sha1)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.EqualValues(t, 3, count)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetLatestCommitStatusForRepoCommitIDs", func(t *testing.T) {
|
||||||
|
commitIDs := []string{sha1}
|
||||||
|
// SQLite has a limit of 1000 for WHERE expression variables per query, use a larger slice to test
|
||||||
|
for i := range 2000 {
|
||||||
|
commitIDs = append(commitIDs, fmt.Sprintf("%040x", i+1))
|
||||||
|
}
|
||||||
|
statuses, err := git_model.GetLatestCommitStatusForRepoCommitIDs(t.Context(), repo1.ID, commitIDs)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, statuses[sha1], 3)
|
||||||
|
latestIndexes := make(map[string]int64)
|
||||||
|
for _, status := range statuses[sha1] {
|
||||||
|
latestIndexes[status.Context] = status.Index
|
||||||
|
}
|
||||||
|
assert.Equal(t, map[string]int64{
|
||||||
|
"ci/awesomeness": 4,
|
||||||
|
"cov/awesomeness": 3,
|
||||||
|
"deploy/awesomeness": 5,
|
||||||
|
}, latestIndexes)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_CalcCommitStatus(t *testing.T) {
|
func Test_CalcCommitStatus(t *testing.T) {
|
||||||
@@ -255,31 +282,10 @@ func TestCommitStatusesApplyDoerPermission(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
git_model.CommitStatusesApplyDoerPermission(t.Context(), doer, statuses)
|
git_model.CommitStatusesApplyDoerPermission(cache.WithCacheContext(t.Context()), doer, statuses)
|
||||||
assert.Equal(t, visibleURL, statuses[0].TargetURL)
|
assert.Equal(t, visibleURL, statuses[0].TargetURL)
|
||||||
assert.Empty(t, statuses[1].TargetURL)
|
assert.Empty(t, statuses[1].TargetURL)
|
||||||
assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL)
|
assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL)
|
||||||
}
|
assert.NotNil(t, statuses[0].Repo)
|
||||||
|
assert.Same(t, statuses[0].Repo, statuses[2].Repo)
|
||||||
func TestGetCountLatestCommitStatus(t *testing.T) {
|
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
|
||||||
|
|
||||||
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
|
||||||
|
|
||||||
sha1 := "1234123412341234123412341234123412341234" // the mocked commit ID in test fixtures
|
|
||||||
|
|
||||||
commitStatuses, err := git_model.GetLatestCommitStatus(t.Context(), repo1.ID, sha1, db.ListOptions{
|
|
||||||
Page: 1,
|
|
||||||
PageSize: 2,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, commitStatuses, 2)
|
|
||||||
assert.Equal(t, commitstatus.CommitStatusFailure, commitStatuses[0].State)
|
|
||||||
assert.Equal(t, "ci/awesomeness", commitStatuses[0].Context)
|
|
||||||
assert.Equal(t, commitstatus.CommitStatusError, commitStatuses[1].State)
|
|
||||||
assert.Equal(t, "deploy/awesomeness", commitStatuses[1].Context)
|
|
||||||
|
|
||||||
count, err := git_model.CountLatestCommitStatus(t.Context(), repo1.ID, sha1)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.EqualValues(t, 3, count)
|
|
||||||
}
|
}
|
||||||
@@ -99,8 +99,8 @@ func (protectBranch *ProtectedBranch) loadGlob() {
|
|||||||
var err error
|
var err error
|
||||||
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
|
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
|
log.Debug("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
|
||||||
protectBranch.globRule = glob.MustCompile(glob.QuoteMeta(protectBranch.RuleName), '/')
|
protectBranch.globRule, _ = glob.Compile(glob.QuoteMeta(protectBranch.RuleName), '/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,9 @@ func (protectBranch *ProtectedBranch) Match(branchName string) bool {
|
|||||||
if protectBranch.isPlainName {
|
if protectBranch.isPlainName {
|
||||||
return strings.EqualFold(protectBranch.RuleName, branchName)
|
return strings.EqualFold(protectBranch.RuleName, branchName)
|
||||||
}
|
}
|
||||||
|
if protectBranch.globRule == nil {
|
||||||
|
return false // in case of invalid glob rule, we don't match anything
|
||||||
|
}
|
||||||
return protectBranch.globRule.Match(branchName)
|
return protectBranch.globRule.Match(branchName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,23 +125,13 @@ func (protectBranch *ProtectedBranch) LoadRepo(ctx context.Context) (err error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CanUserPush returns if some user could push to this protected branch
|
// CanUserPush returns if some user could push to this protected branch
|
||||||
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User) bool {
|
func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
|
||||||
if !protectBranch.CanPush {
|
if !protectBranch.CanPush {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if !protectBranch.EnableWhitelist {
|
if !protectBranch.EnableWhitelist {
|
||||||
if err := protectBranch.LoadRepo(ctx); err != nil {
|
return permissionInRepo.CanWrite(unit.TypeCode)
|
||||||
log.Error("LoadRepo: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAccess, err := access_model.HasAccessUnit(ctx, user, protectBranch.Repo, unit.TypeCode, perm.AccessModeWrite)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("HasAccessUnit: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return writeAccess
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
|
if slices.Contains(protectBranch.WhitelistUserIDs, user.ID) {
|
||||||
@@ -160,17 +152,17 @@ func (protectBranch *ProtectedBranch) CanUserPush(ctx context.Context, user *use
|
|||||||
|
|
||||||
// CanUserForcePush returns if some user could force push to this protected branch
|
// CanUserForcePush returns if some user could force push to this protected branch
|
||||||
// Since force-push extends normal push, we also check if user has regular push access
|
// Since force-push extends normal push, we also check if user has regular push access
|
||||||
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User) bool {
|
func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user *user_model.User, permissionInRepo access_model.Permission) bool {
|
||||||
if !protectBranch.CanForcePush {
|
if !protectBranch.CanForcePush {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if !protectBranch.EnableForcePushAllowlist {
|
if !protectBranch.EnableForcePushAllowlist {
|
||||||
return protectBranch.CanUserPush(ctx, user)
|
return protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
|
if slices.Contains(protectBranch.ForcePushAllowlistUserIDs, user.ID) {
|
||||||
return protectBranch.CanUserPush(ctx, user)
|
return protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
|
if len(protectBranch.ForcePushAllowlistTeamIDs) == 0 {
|
||||||
@@ -182,7 +174,7 @@ func (protectBranch *ProtectedBranch) CanUserForcePush(ctx context.Context, user
|
|||||||
log.Error("IsUserInTeams: %v", err)
|
log.Error("IsUserInTeams: %v", err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return in && protectBranch.CanUserPush(ctx, user)
|
return in && protectBranch.CanUserPush(ctx, user, permissionInRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
|
// IsUserMergeWhitelisted checks if some user is whitelisted to merge to this branch
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/modules/glob"
|
"gitea.dev/modules/glob"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,10 +56,17 @@ func FindRepoProtectedBranchRules(ctx context.Context, repoID int64) (ProtectedB
|
|||||||
// FindAllMatchedBranches find all matched branches
|
// FindAllMatchedBranches find all matched branches
|
||||||
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
|
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
|
||||||
results := make([]string, 0, 10)
|
results := make([]string, 0, 10)
|
||||||
|
rule, err := glob.Compile(ruleName)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Failed to compile rule %s: %v", ruleName, err)
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = 100
|
||||||
for page := 1; ; page++ {
|
for page := 1; ; page++ {
|
||||||
brancheNames, err := FindBranchNames(ctx, FindBranchOptions{
|
branchNames, err := FindBranchNames(ctx, FindBranchOptions{
|
||||||
ListOptions: db.ListOptions{
|
ListOptions: db.ListOptions{
|
||||||
PageSize: 100,
|
PageSize: pageSize,
|
||||||
Page: page,
|
Page: page,
|
||||||
},
|
},
|
||||||
RepoID: repoID,
|
RepoID: repoID,
|
||||||
@@ -67,14 +75,12 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rule := glob.MustCompile(ruleName)
|
for _, branch := range branchNames {
|
||||||
|
|
||||||
for _, branch := range brancheNames {
|
|
||||||
if rule.Match(branch) {
|
if rule.Match(branch) {
|
||||||
results = append(results, branch)
|
results = append(results, branch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(brancheNames) < 100 {
|
if len(branchNames) < pageSize {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrCommentNotExist represents a "CommentNotExist" kind of error.
|
// ErrCommentNotExist represents a "CommentNotExist" kind of error.
|
||||||
@@ -369,12 +370,19 @@ func (c *Comment) LoadIssue(ctx context.Context) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ xorm.BeforeInsertProcessor = (*Comment)(nil)
|
||||||
|
_ xorm.BeforeUpdateProcessor = (*Comment)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
// BeforeInsert will be invoked by XORM before inserting a record
|
// BeforeInsert will be invoked by XORM before inserting a record
|
||||||
func (c *Comment) BeforeInsert() {
|
func (c *Comment) BeforeInsert() {
|
||||||
c.PatchQuoted = c.Patch
|
c.PatchQuoted = c.Patch
|
||||||
if !utf8.ValidString(c.Patch) {
|
if !utf8.ValidString(c.Patch) {
|
||||||
c.PatchQuoted = strconv.Quote(c.Patch)
|
c.PatchQuoted = strconv.Quote(c.Patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sanitizeDatabaseStringContent(&c.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BeforeUpdate will be invoked by XORM before updating a record
|
// BeforeUpdate will be invoked by XORM before updating a record
|
||||||
@@ -383,6 +391,8 @@ func (c *Comment) BeforeUpdate() {
|
|||||||
if !utf8.ValidString(c.Patch) {
|
if !utf8.ValidString(c.Patch) {
|
||||||
c.PatchQuoted = strconv.Quote(c.Patch)
|
c.PatchQuoted = strconv.Quote(c.Patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sanitizeDatabaseStringContent(&c.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
|
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
|
||||||
@@ -1164,6 +1174,11 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
|
|||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
return ErrCommentAlreadyChanged
|
return ErrCommentAlreadyChanged
|
||||||
}
|
}
|
||||||
|
if c.Type == CommentTypeReview {
|
||||||
|
if _, err := db.GetEngine(ctx).ID(c.ReviewID).Cols("content").NoAutoTime().Update(&Review{Content: c.Content}); err != nil { // updated_unix picks a reviewer's latest review
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := c.LoadIssue(ctx); err != nil {
|
if err := c.LoadIssue(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-4
@@ -11,6 +11,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
project_model "gitea.dev/models/project"
|
project_model "gitea.dev/models/project"
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrIssueNotExist represents a "IssueNotExist" kind of error.
|
// ErrIssueNotExist represents a "IssueNotExist" kind of error.
|
||||||
@@ -53,7 +55,7 @@ var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already
|
|||||||
// Issue represents an issue or pull request of repository.
|
// Issue represents an issue or pull request of repository.
|
||||||
type Issue struct {
|
type Issue struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"`
|
RepoID int64 `xorm:"INDEX(repo_closed_pull) UNIQUE(repo_index)"`
|
||||||
Repo *repo_model.Repository `xorm:"-"`
|
Repo *repo_model.Repository `xorm:"-"`
|
||||||
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
|
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
|
||||||
PosterID int64 `xorm:"INDEX"`
|
PosterID int64 `xorm:"INDEX"`
|
||||||
@@ -75,13 +77,13 @@ type Issue struct {
|
|||||||
AssigneeID int64 `xorm:"-"`
|
AssigneeID int64 `xorm:"-"`
|
||||||
Assignee *user_model.User `xorm:"-"`
|
Assignee *user_model.User `xorm:"-"`
|
||||||
isAssigneeLoaded bool `xorm:"-"`
|
isAssigneeLoaded bool `xorm:"-"`
|
||||||
IsClosed bool `xorm:"INDEX"`
|
IsClosed bool `xorm:"INDEX(repo_closed_pull)"`
|
||||||
IsRead bool `xorm:"-"`
|
IsRead bool `xorm:"-"`
|
||||||
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
|
IsPull bool `xorm:"INDEX(repo_closed_pull)"` // Indicates whether is a pull request or not.
|
||||||
PullRequest *PullRequest `xorm:"-"`
|
PullRequest *PullRequest `xorm:"-"`
|
||||||
NumComments int
|
NumComments int
|
||||||
|
|
||||||
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
|
// TODO: ISSUE-SIDEBAR-REF-SELECTOR: see "repo/issue/branch_selector_field.tmpl"
|
||||||
Ref string
|
Ref string
|
||||||
|
|
||||||
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
|
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
|
||||||
@@ -123,6 +125,27 @@ func init() {
|
|||||||
db.RegisterModel(new(IssueIndex))
|
db.RegisterModel(new(IssueIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeDatabaseStringContent(s *string) {
|
||||||
|
// postgresql doesn't like "\x00"
|
||||||
|
// this is only a simple workaround for issues like https://github.com/go-gitea/gitea/issues/28890 and https://github.com/go-gitea/gitea/issues/40003
|
||||||
|
*s = strings.ReplaceAll(*s, "\x00", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ xorm.BeforeInsertProcessor = (*Issue)(nil)
|
||||||
|
_ xorm.BeforeUpdateProcessor = (*Issue)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (issue *Issue) BeforeInsert() {
|
||||||
|
sanitizeDatabaseStringContent(&issue.Title)
|
||||||
|
sanitizeDatabaseStringContent(&issue.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (issue *Issue) BeforeUpdate() {
|
||||||
|
sanitizeDatabaseStringContent(&issue.Title)
|
||||||
|
sanitizeDatabaseStringContent(&issue.Content)
|
||||||
|
}
|
||||||
|
|
||||||
// LoadTotalTimes load total tracked time
|
// LoadTotalTimes load total tracked time
|
||||||
func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
|
func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
|
||||||
opts := FindTrackedTimesOptions{IssueID: issue.ID}
|
opts := FindTrackedTimesOptions{IssueID: issue.ID}
|
||||||
|
|||||||
@@ -75,11 +75,11 @@ func applySorts(sess db.Session, sortType string, priorityRepoID int64) {
|
|||||||
// Since this sortType is dynamically created, it has to be treated specially.
|
// Since this sortType is dynamically created, it has to be treated specially.
|
||||||
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
|
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
|
||||||
scope := after
|
scope := after
|
||||||
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
|
// a subquery instead of a JOIN keeps one row per issue, "exclusive_order=0" means unset and COALESCE sorts it last on every DB
|
||||||
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
|
sess.OrderBy("COALESCE((SELECT MIN(label.exclusive_order) FROM issue_label"+
|
||||||
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
|
" INNER JOIN label ON label.id = issue_label.label_id"+
|
||||||
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
|
" WHERE issue_label.issue_id = issue.id AND label.exclusive_order <> 0 AND label.name LIKE ?), 2147483647) ASC", scope+"/%").
|
||||||
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id")
|
Desc("issue.id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,10 +218,7 @@ func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
|
|||||||
opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
|
opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
|
||||||
}
|
}
|
||||||
if opts.AllPublic {
|
if opts.AllPublic {
|
||||||
if opts.RepoCond == nil {
|
opts.RepoCond = builder.Or(opts.RepoCond, repo_model.PublicRepoUnderPublicOwnerCond())
|
||||||
opts.RepoCond = builder.NewCond()
|
|
||||||
}
|
|
||||||
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond())))
|
|
||||||
}
|
}
|
||||||
if opts.RepoCond != nil {
|
if opts.RepoCond != nil {
|
||||||
sess.And(opts.RepoCond)
|
sess.And(opts.RepoCond)
|
||||||
@@ -402,22 +399,17 @@ func applyReviewRequestedCondition(sess db.Session, reviewRequestedID int64) {
|
|||||||
From("team_user").
|
From("team_user").
|
||||||
Where(builder.Eq{"team_user.uid": reviewRequestedID})
|
Where(builder.Eq{"team_user.uid": reviewRequestedID})
|
||||||
|
|
||||||
// if the review is approved or rejected, it should not be shown in the review requested list
|
|
||||||
maxReview := builder.Select("MAX(r.id)").
|
|
||||||
From("review as r").
|
|
||||||
Where(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})).
|
|
||||||
GroupBy("r.issue_id, r.reviewer_id, r.reviewer_team_id")
|
|
||||||
|
|
||||||
subQuery := builder.Select("review.issue_id").
|
subQuery := builder.Select("review.issue_id").
|
||||||
From("review").
|
From("review").
|
||||||
Where(builder.And(
|
Where(builder.Eq{"review.type": ReviewTypeRequest}).
|
||||||
builder.Eq{"review.type": ReviewTypeRequest},
|
And(builder.Or(
|
||||||
builder.Or(
|
|
||||||
builder.Eq{"review.reviewer_id": reviewRequestedID},
|
builder.Eq{"review.reviewer_id": reviewRequestedID},
|
||||||
builder.In("review.reviewer_team_id", existInTeamQuery),
|
builder.In("review.reviewer_team_id", existInTeamQuery),
|
||||||
),
|
)).
|
||||||
builder.In("review.id", maxReview),
|
// if the review is approved or rejected, it should not be shown in the review requested list
|
||||||
))
|
And(builder.NotExists(builder.Select("1").From("review as r").
|
||||||
|
Where(builder.Expr("r.issue_id = review.issue_id AND r.reviewer_id = review.reviewer_id AND r.reviewer_team_id = review.reviewer_team_id AND r.id > review.id").
|
||||||
|
And(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})))))
|
||||||
sess.Where("issue.poster_id <> ?", reviewRequestedID).
|
sess.Where("issue.poster_id <> ?", reviewRequestedID).
|
||||||
And(builder.In("issue.id", subQuery))
|
And(builder.In("issue.id", subQuery))
|
||||||
}
|
}
|
||||||
@@ -477,7 +469,7 @@ func applySubscribedCondition(sess db.Session, subscriberID int64) {
|
|||||||
Select("repo_id").
|
Select("repo_id").
|
||||||
From("watch").
|
From("watch").
|
||||||
Where(builder.And(builder.Eq{"user_id": subscriberID},
|
Where(builder.And(builder.Eq{"user_id": subscriberID},
|
||||||
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))),
|
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto, repo_model.WatchModeCustom))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-17
@@ -269,27 +269,23 @@ func UpdateLabel(ctx context.Context, l *Label) error {
|
|||||||
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
|
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLabel delete a label
|
// DeleteRepoLabel deletes a repository label.
|
||||||
func DeleteLabel(ctx context.Context, id, labelID int64) error {
|
func DeleteRepoLabel(ctx context.Context, repoID, labelID int64) error {
|
||||||
l, err := GetLabelByID(ctx, labelID)
|
return deleteLabel(ctx, labelID, builder.Eq{"repo_id": repoID, "org_id": 0})
|
||||||
if err != nil {
|
|
||||||
if IsErrLabelNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteOrgLabel deletes an organization label.
|
||||||
|
func DeleteOrgLabel(ctx context.Context, orgID, labelID int64) error {
|
||||||
|
return deleteLabel(ctx, labelID, builder.Eq{"org_id": orgID, "repo_id": 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteLabel(ctx context.Context, labelID int64, ownerCond builder.Cond) error {
|
||||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
if l.BelongsToOrg() && l.OrgID != id {
|
deleted, err := db.GetEngine(ctx).ID(labelID).Where(ownerCond).Delete(new(Label))
|
||||||
return nil
|
if err != nil || deleted == 0 {
|
||||||
}
|
|
||||||
if l.BelongsToRepo() && l.RepoID != id {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err = db.DeleteByID[Label](ctx, labelID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
} else if _, err = db.GetEngine(ctx).
|
}
|
||||||
|
if _, err = db.GetEngine(ctx).
|
||||||
Where("label_id = ?", labelID).
|
Where("label_id = ?", labelID).
|
||||||
Delete(new(IssueLabel)); err != nil {
|
Delete(new(IssueLabel)); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -320,13 +320,29 @@ func TestUpdateLabel(t *testing.T) {
|
|||||||
func TestDeleteLabel(t *testing.T) {
|
func TestDeleteLabel(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
|
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
|
||||||
assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID))
|
orgLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 3})
|
||||||
|
repoLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 10, RepoID: orgLabel.OrgID})
|
||||||
|
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), repoLabel.RepoID, repoLabel.ID))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: repoLabel.ID})
|
||||||
|
|
||||||
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), orgLabel.OrgID, orgLabel.ID))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: orgLabel.ID})
|
||||||
|
|
||||||
|
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), unittest.NonexistentID, orgLabel.ID))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: orgLabel.ID})
|
||||||
|
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), orgLabel.OrgID, orgLabel.ID))
|
||||||
|
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: orgLabel.ID})
|
||||||
|
|
||||||
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), unittest.NonexistentID, label.ID))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: label.ID})
|
||||||
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), label.RepoID, label.ID))
|
||||||
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID})
|
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID})
|
||||||
|
|
||||||
assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID))
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), label.RepoID, label.ID))
|
||||||
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID})
|
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID})
|
||||||
|
|
||||||
assert.NoError(t, issues_model.DeleteLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
|
||||||
|
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
|
||||||
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
|
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type FindMilestoneOptions struct {
|
|||||||
SortType string
|
SortType string
|
||||||
RepoCond builder.Cond
|
RepoCond builder.Cond
|
||||||
RepoIDs []int64
|
RepoIDs []int64
|
||||||
|
ProjectID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (opts FindMilestoneOptions) ToConds() builder.Cond {
|
func (opts FindMilestoneOptions) ToConds() builder.Cond {
|
||||||
@@ -64,6 +65,11 @@ func (opts FindMilestoneOptions) ToConds() builder.Cond {
|
|||||||
if len(opts.Name) != 0 {
|
if len(opts.Name) != 0 {
|
||||||
cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name))
|
cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name))
|
||||||
}
|
}
|
||||||
|
if opts.ProjectID != 0 {
|
||||||
|
cond = cond.And(builder.In("id", builder.Select("issue.milestone_id").From("issue").
|
||||||
|
InnerJoin("project_issue", "project_issue.issue_id = issue.id").
|
||||||
|
Where(builder.Eq{"project_issue.project_id": opts.ProjectID})))
|
||||||
|
}
|
||||||
|
|
||||||
return cond
|
return cond
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -378,7 +378,7 @@ func (pr *PullRequest) getReviewedByLines(ctx context.Context, writer io.Writer)
|
|||||||
reviewersWritten := 0
|
reviewersWritten := 0
|
||||||
|
|
||||||
for _, review := range reviews {
|
for _, review := range reviews {
|
||||||
if maxReviewers > 0 && reviewersWritten > maxReviewers {
|
if maxReviewers > 0 && reviewersWritten >= maxReviewers {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +407,21 @@ func (pr *PullRequest) GetGitHeadRefName() string { // TODO: make it return RefN
|
|||||||
return git.RefNameFromPullIndex(pr.Index).String()
|
return git.RefNameFromPullIndex(pr.Index).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pr *PullRequest) GetInstructionsCliArgs() (ret struct {
|
||||||
|
BaseBranchArg string
|
||||||
|
HeadBranchArg string
|
||||||
|
LocalBranchArg string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
ret.BaseBranchArg = util.ShellEscape(pr.BaseBranch)
|
||||||
|
ret.HeadBranchArg = util.ShellEscape(pr.HeadBranch)
|
||||||
|
ret.LocalBranchArg = ret.HeadBranchArg
|
||||||
|
if pr.HeadRepo != nil && pr.HeadRepoID != pr.BaseRepoID {
|
||||||
|
ret.LocalBranchArg = util.ShellEscape(pr.HeadRepo.OwnerName) + "-" + ret.HeadBranchArg
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)
|
// GetReviewCommentsCount returns the number of review comments made on the diff of a PR review (not including comments on commits or issues in a PR)
|
||||||
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
|
func (pr *PullRequest) GetReviewCommentsCount(ctx context.Context) int {
|
||||||
opts := FindCommentsOptions{
|
opts := FindCommentsOptions{
|
||||||
|
|||||||
@@ -335,8 +335,8 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
|
|||||||
"reviewer_team_id": 0,
|
"reviewer_team_id": 0,
|
||||||
}).
|
}).
|
||||||
GroupBy("issue_id, reviewer_id")
|
GroupBy("issue_id, reviewer_id")
|
||||||
// Get latest review of each reviewer, sorted in order they were made
|
// Get latest review of each reviewer, sorted in order they were made. Joined because MySQL scans all reviews for "id IN (subquery)", xorm aliases the subquery "review".
|
||||||
if err := db.GetEngine(ctx).In("id", subQuery).OrderBy("review.updated_unix ASC").Find(&reviews); err != nil {
|
if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQuery, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&reviews); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
|
|||||||
"reviewer_team_id": 0,
|
"reviewer_team_id": 0,
|
||||||
}).
|
}).
|
||||||
GroupBy("issue_id, reviewer_team_id")
|
GroupBy("issue_id, reviewer_team_id")
|
||||||
if err := db.GetEngine(ctx).In("id", subQueryTeam).OrderBy("review.updated_unix ASC").Find(&teamReviewRequests); err != nil {
|
if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQueryTeam, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&teamReviewRequests); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -403,6 +404,10 @@ func testGetApprovers(t *testing.T) {
|
|||||||
approvers := pr.GetApprovers(t.Context())
|
approvers := pr.GetApprovers(t.Context())
|
||||||
expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n"
|
expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n"
|
||||||
assert.Equal(t, expected, approvers)
|
assert.Equal(t, expected, approvers)
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.Repository.PullRequest.DefaultMergeMessageMaxApprovers, 1)()
|
||||||
|
approvers = pr.GetApprovers(t.Context())
|
||||||
|
assert.Equal(t, "Reviewed-by: User Five <user5@example.com>\n", approvers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGetPullRequestByMergedCommit(t *testing.T) {
|
func testGetPullRequestByMergedCommit(t *testing.T) {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder {
|
|||||||
|
|
||||||
func (opts FindOrgOptions) ToConds() builder.Cond {
|
func (opts FindOrgOptions) ToConds() builder.Cond {
|
||||||
var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization}
|
var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization}
|
||||||
if opts.UserID > 0 {
|
if opts.UserID != 0 {
|
||||||
cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludeVisibility == structs.VisibleTypePrivate)))
|
cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludeVisibility == structs.VisibleTypePrivate)))
|
||||||
}
|
}
|
||||||
// public=0, limited=1, private=2
|
// public=0, limited=1, private=2
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import (
|
|||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TeamRepo represents an team-repository relation.
|
// TeamRepo represents an org-team-repository relation.
|
||||||
type TeamRepo struct {
|
type TeamRepo struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
OrgID int64 `xorm:"INDEX"`
|
OrgID int64 `xorm:"INDEX"`
|
||||||
TeamID int64 `xorm:"UNIQUE(s)"`
|
TeamID int64 `xorm:"UNIQUE(s)"`
|
||||||
RepoID int64 `xorm:"UNIQUE(s)"`
|
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasTeamRepo returns true if given repository belongs to team.
|
// HasTeamRepo returns true if given repository belongs to team.
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import (
|
|||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TeamUser represents an team-user relation.
|
// TeamUser represents a team-user relation.
|
||||||
type TeamUser struct {
|
type TeamUser struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
OrgID int64 `xorm:"INDEX"`
|
OrgID int64 `xorm:"INDEX"`
|
||||||
TeamID int64 `xorm:"UNIQUE(s)"`
|
TeamID int64 `xorm:"UNIQUE(s)"`
|
||||||
UID int64 `xorm:"UNIQUE(s)"`
|
UID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTeamMember returns true if given user is a member of team.
|
// IsTeamMember returns true if given user is a member of team.
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func GetTotalUnreferencedBlobSize(ctx context.Context) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsBlobAccessibleForUser tests if the user has access to the blob
|
// IsBlobAccessibleForUser tests if the user has access to the blob
|
||||||
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User) (bool, error) {
|
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User, publicOnly bool) (bool, error) {
|
||||||
if user.IsAdmin {
|
if user.IsAdmin && !publicOnly {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
|
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
|
||||||
@@ -153,6 +153,9 @@ func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model
|
|||||||
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
|
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
|
||||||
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
|
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
|
||||||
)
|
)
|
||||||
|
if publicOnly {
|
||||||
|
cond = cond.And(builder.Eq{"`user`.visibility": structs.VisibleTypePublic})
|
||||||
|
}
|
||||||
|
|
||||||
return db.GetEngine(ctx).
|
return db.GetEngine(ctx).
|
||||||
Table("package_blob").
|
Table("package_blob").
|
||||||
|
|||||||
@@ -64,11 +64,15 @@ func TestIsBlobAccessibleForRestrictedUser(t *testing.T) {
|
|||||||
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
|
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}))
|
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, accessible)
|
assert.True(t, accessible)
|
||||||
|
|
||||||
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}))
|
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, accessible)
|
||||||
|
|
||||||
|
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, accessible)
|
assert.False(t, accessible)
|
||||||
}
|
}
|
||||||
@@ -30,9 +30,9 @@ const (
|
|||||||
// PackageProperty represents a property of a package, version or file
|
// PackageProperty represents a property of a package, version or file
|
||||||
type PackageProperty struct {
|
type PackageProperty struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
RefType PropertyType `xorm:"INDEX NOT NULL"`
|
RefType PropertyType `xorm:"NOT NULL"`
|
||||||
RefID int64 `xorm:"INDEX NOT NULL"`
|
RefID int64 `xorm:"INDEX(ref_id_name) NOT NULL"`
|
||||||
Name string `xorm:"INDEX NOT NULL"`
|
Name string `xorm:"INDEX(ref_id_name) NOT NULL"`
|
||||||
Value string `xorm:"LONGTEXT NOT NULL"`
|
Value string `xorm:"LONGTEXT NOT NULL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
type Access struct {
|
type Access struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
UserID int64 `xorm:"UNIQUE(s)"`
|
UserID int64 `xorm:"UNIQUE(s)"`
|
||||||
RepoID int64 `xorm:"UNIQUE(s)"`
|
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||||
Mode perm.AccessMode
|
Mode perm.AccessMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func (opts SearchOptions) ToConds() builder.Cond {
|
|||||||
if opts.Type > 0 {
|
if opts.Type > 0 {
|
||||||
cond = cond.And(builder.Eq{"type": opts.Type})
|
cond = cond.And(builder.Eq{"type": opts.Type})
|
||||||
}
|
}
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID != 0 {
|
||||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
"gitea.dev/modules/markup/markdown"
|
"gitea.dev/modules/markup/markdown"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
_ "gitea.dev/modules/markup/orgmode"
|
_ "gitea.dev/modules/markup/orgmode"
|
||||||
|
|
||||||
@@ -80,6 +81,23 @@ func TestRepoFile(t *testing.T) {
|
|||||||
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
|
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
|
||||||
</video>`, rendered)
|
</video>`, rendered)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("PictureSource", func(t *testing.T) {
|
||||||
|
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}).
|
||||||
|
WithMarkupType(markdown.MarkupName)
|
||||||
|
rendered, err := testRenderString(rctx, `<picture><source media="(prefers-color-scheme: dark)" srcset="dark.svg"><img src="light.svg"></picture>`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, test.NormalizeHTMLSpaces(`
|
||||||
|
<p>
|
||||||
|
<a href="/user2/repo1/src/branch/main/light.svg" target="_blank" rel="nofollow noopener">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="/user2/repo1/media/branch/main/dark.svg"/>
|
||||||
|
<img src="/user2/repo1/media/branch/main/light.svg"/>
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
`), test.NormalizeHTMLSpaces(rendered))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRepoFileOrgMode(t *testing.T) {
|
func TestRepoFileOrgMode(t *testing.T) {
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ func ExistAttachmentsByUUID(ctx context.Context, uuid string) (bool, error) {
|
|||||||
|
|
||||||
// GetAttachmentsByIssueID returns all attachments of an issue.
|
// GetAttachmentsByIssueID returns all attachments of an issue.
|
||||||
func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment, error) {
|
func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment, error) {
|
||||||
|
if issueID == 0 { // unsaved issue, would match every issue-less attachment
|
||||||
|
return []*Attachment{}, nil
|
||||||
|
}
|
||||||
attachments := make([]*Attachment, 0, 10)
|
attachments := make([]*Attachment, 0, 10)
|
||||||
return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments)
|
return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ func TestGetByCommentOrIssueID(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, attachments, 1)
|
assert.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
attachments, err = repo_model.GetAttachmentsByIssueID(t.Context(), 0)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, attachments)
|
||||||
|
|
||||||
attachments, err = repo_model.GetAttachmentsByCommentID(t.Context(), 1)
|
attachments, err = repo_model.GetAttachmentsByCommentID(t.Context(), 1)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, attachments, 2)
|
assert.Len(t, attachments, 2)
|
||||||
|
|||||||
+26
-19
@@ -191,6 +191,10 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i := range attachments {
|
for i := range attachments {
|
||||||
|
if attachments[i].IssueID != 0 || attachments[i].CommentID != 0 || attachments[i].ReleaseID != 0 {
|
||||||
|
return util.NewPermissionDeniedErrorf("attachment is already linked")
|
||||||
|
}
|
||||||
|
|
||||||
if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff {
|
if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff {
|
||||||
attachments[i].RepoID = rel.RepoID
|
attachments[i].RepoID = rel.RepoID
|
||||||
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil {
|
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil {
|
||||||
@@ -201,10 +205,6 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
|
|||||||
if attachments[i].RepoID != rel.RepoID {
|
if attachments[i].RepoID != rel.RepoID {
|
||||||
return util.NewPermissionDeniedErrorf("attachment belongs to different repository")
|
return util.NewPermissionDeniedErrorf("attachment belongs to different repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
if attachments[i].ReleaseID != 0 {
|
|
||||||
return util.NewPermissionDeniedErrorf("release permission denied")
|
|
||||||
}
|
|
||||||
attachments[i].ReleaseID = releaseID
|
attachments[i].ReleaseID = releaseID
|
||||||
// No assign value could be 0, so ignore AllCols().
|
// No assign value could be 0, so ignore AllCols().
|
||||||
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("release_id").Update(attachments[i]); err != nil {
|
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("release_id").Update(attachments[i]); err != nil {
|
||||||
@@ -357,21 +357,21 @@ func GetLatestReleaseByRepoID(ctx context.Context, repoID int64) (*Release, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
type releaseMetaSearch struct {
|
type releaseMetaSearch struct {
|
||||||
ID []int64
|
IDs []int64
|
||||||
Rel []*Release
|
Releases []*Release
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s releaseMetaSearch) Len() int {
|
func (s releaseMetaSearch) Len() int {
|
||||||
return len(s.ID)
|
return len(s.IDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s releaseMetaSearch) Swap(i, j int) {
|
func (s releaseMetaSearch) Swap(i, j int) {
|
||||||
s.ID[i], s.ID[j] = s.ID[j], s.ID[i]
|
s.IDs[i], s.IDs[j] = s.IDs[j], s.IDs[i]
|
||||||
s.Rel[i], s.Rel[j] = s.Rel[j], s.Rel[i]
|
s.Releases[i], s.Releases[j] = s.Releases[j], s.Releases[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s releaseMetaSearch) Less(i, j int) bool {
|
func (s releaseMetaSearch) Less(i, j int) bool {
|
||||||
return s.ID[i] < s.ID[j]
|
return s.IDs[i] < s.IDs[j]
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasDuplicateName(attaches []*Attachment) bool {
|
func hasDuplicateName(attaches []*Attachment) bool {
|
||||||
@@ -396,19 +396,26 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
|
|||||||
// then merge join them
|
// then merge join them
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
sortedRels := releaseMetaSearch{ID: make([]int64, len(rels)), Rel: make([]*Release, len(rels))}
|
sortedRels := releaseMetaSearch{IDs: make([]int64, 0, len(rels)), Releases: make([]*Release, 0, len(rels))}
|
||||||
var attachments []*Attachment
|
var attachments []*Attachment
|
||||||
for index, element := range rels {
|
for _, rel := range rels {
|
||||||
element.Attachments = []*Attachment{}
|
// if a provided "Release" has no ID, don't query it, otherwise, the "IN" query will return all other attachments for the repo
|
||||||
sortedRels.ID[index] = element.ID
|
if rel.ID == 0 {
|
||||||
sortedRels.Rel[index] = element
|
continue
|
||||||
|
}
|
||||||
|
rel.Attachments = []*Attachment{}
|
||||||
|
sortedRels.IDs = append(sortedRels.IDs, rel.ID)
|
||||||
|
sortedRels.Releases = append(sortedRels.Releases, rel)
|
||||||
|
}
|
||||||
|
if len(sortedRels.IDs) == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
sort.Sort(sortedRels)
|
sort.Sort(sortedRels)
|
||||||
|
|
||||||
// Select attachments
|
// Select attachments
|
||||||
err = db.GetEngine(ctx).
|
err = db.GetEngine(ctx).
|
||||||
Asc("release_id", "name").
|
Asc("release_id", "name").
|
||||||
In("release_id", sortedRels.ID).
|
In("release_id", sortedRels.IDs).
|
||||||
Find(&attachments)
|
Find(&attachments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -417,14 +424,14 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
|
|||||||
// merge join
|
// merge join
|
||||||
currentIndex := 0
|
currentIndex := 0
|
||||||
for _, attachment := range attachments {
|
for _, attachment := range attachments {
|
||||||
for sortedRels.ID[currentIndex] < attachment.ReleaseID {
|
for sortedRels.IDs[currentIndex] < attachment.ReleaseID {
|
||||||
currentIndex++
|
currentIndex++
|
||||||
}
|
}
|
||||||
sortedRels.Rel[currentIndex].Attachments = append(sortedRels.Rel[currentIndex].Attachments, attachment)
|
sortedRels.Releases[currentIndex].Attachments = append(sortedRels.Releases[currentIndex].Attachments, attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Makes URL's predictable
|
// Makes URL's predictable
|
||||||
for _, release := range rels {
|
for _, release := range sortedRels.Releases {
|
||||||
// If we have no Repo, we don't need to execute this loop
|
// If we have no Repo, we don't need to execute this loop
|
||||||
if release.Repo == nil {
|
if release.Repo == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) {
|
|||||||
assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure")
|
assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAddReleaseAttachmentsRejectsIssueAttachment(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
uuid := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"
|
||||||
|
err := AddReleaseAttachments(t.Context(), 1, []string{uuid})
|
||||||
|
assert.ErrorIs(t, err, util.ErrPermissionDenied)
|
||||||
|
|
||||||
|
attach, err := GetAttachmentByUUID(t.Context(), uuid)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Zero(t, attach.ReleaseID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) {
|
func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -494,10 +494,9 @@ func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]strin
|
|||||||
repo.MustOwner(ctx)
|
repo.MustOwner(ctx)
|
||||||
if repo.Owner.IsOrganization() {
|
if repo.Owner.IsOrganization() {
|
||||||
teams := make([]string, 0, 5)
|
teams := make([]string, 0, 5)
|
||||||
_ = db.GetEngine(ctx).Table("team_repo").
|
_ = db.GetEngine(ctx).Select("team.lower_name").Table("team_repo").
|
||||||
Join("INNER", "team", "team.id = team_repo.team_id").
|
Join("INNER", "team", "team.id = team_repo.team_id").
|
||||||
Where("team_repo.repo_id = ?", repo.ID).
|
Where("team_repo.repo_id = ?", repo.ID).
|
||||||
Select("team.lower_name").
|
|
||||||
OrderBy("team.lower_name").
|
OrderBy("team.lower_name").
|
||||||
Find(&teams)
|
Find(&teams)
|
||||||
metas["teams"] = "," + strings.Join(teams, ",") + ","
|
metas["teams"] = "," + strings.Join(teams, ",") + ","
|
||||||
@@ -616,6 +615,15 @@ func ComposeHTTPSCloneURL(ctx context.Context, owner, repo string) string {
|
|||||||
|
|
||||||
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
|
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
|
||||||
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
|
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
|
||||||
|
return composeSSHCloneURL(doer, ownerName, repoName, setting.Repository.UseCompatSSHURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeSSHCloneURI is like ComposeSSHCloneURL but always returns the "ssh://" form, because "go get" rejects scp-style addresses
|
||||||
|
func ComposeSSHCloneURI(doer *user_model.User, ownerName, repoName string) string {
|
||||||
|
return composeSSHCloneURL(doer, ownerName, repoName, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeSSHCloneURL(doer *user_model.User, ownerName, repoName string, useURI bool) string {
|
||||||
sshUser := setting.SSH.User
|
sshUser := setting.SSH.User
|
||||||
sshDomain := setting.SSH.Domain
|
sshDomain := setting.SSH.Domain
|
||||||
|
|
||||||
@@ -642,7 +650,7 @@ func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) strin
|
|||||||
if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil {
|
if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil {
|
||||||
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
|
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
|
||||||
}
|
}
|
||||||
if setting.Repository.UseCompatSSHURI {
|
if useURI {
|
||||||
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
||||||
|
|||||||
@@ -373,8 +373,7 @@ func SearchRepositoryCondition(opts SearchRepoOptions) builder.Cond {
|
|||||||
cond := builder.NewCond()
|
cond := builder.NewCond()
|
||||||
|
|
||||||
if opts.Private {
|
if opts.Private {
|
||||||
if opts.Actor != nil && !opts.Actor.IsAdmin && opts.Actor.ID != opts.OwnerID {
|
if opts.Actor != nil && !opts.Actor.IsAdmin {
|
||||||
// OK we're in the context of a User
|
|
||||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -762,7 +761,8 @@ func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user
|
|||||||
func PublicRepoUnderPublicOwnerCond() builder.Cond {
|
func PublicRepoUnderPublicOwnerCond() builder.Cond {
|
||||||
return builder.And(
|
return builder.And(
|
||||||
builder.Eq{"`repository`.is_private": false},
|
builder.Eq{"`repository`.is_private": false},
|
||||||
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePublic})),
|
builder.Exists(builder.Select("1").From("`user` repo_owner").
|
||||||
|
Where(builder.Expr("repo_owner.id = `repository`.owner_id").And(builder.Eq{"repo_owner.visibility": structs.VisibleTypePublic}))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ func TestComposeSSHCloneURL(t *testing.T) {
|
|||||||
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
||||||
setting.Repository.UseCompatSSHURI = true
|
setting.Repository.UseCompatSSHURI = true
|
||||||
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
||||||
|
setting.Repository.UseCompatSSHURI = false
|
||||||
|
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURI(nil, "user", "repo"))
|
||||||
// test SSH_DOMAIN while use non-standard SSH port
|
// test SSH_DOMAIN while use non-standard SSH port
|
||||||
setting.SSH.Port = 123
|
setting.SSH.Port = 123
|
||||||
setting.Repository.UseCompatSSHURI = false
|
setting.Repository.UseCompatSSHURI = false
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func IsErrRepoAlreadyExist(err error) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (err ErrRepoAlreadyExist) Error() string {
|
func (err ErrRepoAlreadyExist) Error() string {
|
||||||
return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name)
|
return fmt.Sprintf("repository already exists [owner: %s, repo: %s]", err.Uname, err.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (err ErrRepoAlreadyExist) Unwrap() error {
|
func (err ErrRepoAlreadyExist) Unwrap() error {
|
||||||
|
|||||||
+32
-8
@@ -20,9 +20,10 @@ type WatchMode int8
|
|||||||
const (
|
const (
|
||||||
WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned
|
WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned
|
||||||
|
|
||||||
WatchModeNormal // 1 proactively watching (all or custom)
|
WatchModeNormal // 1 proactively watching (all, or custom before WatchModeCustom)
|
||||||
WatchModeDont // 2 ignore the repo
|
WatchModeDont // 2 ignore the repo
|
||||||
WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges)
|
WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges)
|
||||||
|
WatchModeCustom // 4 watching the events picked in the custom watch options
|
||||||
)
|
)
|
||||||
|
|
||||||
// WatchType is the `watch` column gating one kind of notification
|
// WatchType is the `watch` column gating one kind of notification
|
||||||
@@ -38,7 +39,7 @@ const (
|
|||||||
type Watch struct {
|
type Watch struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
UserID int64 `xorm:"UNIQUE(watch)"`
|
UserID int64 `xorm:"UNIQUE(watch)"`
|
||||||
RepoID int64 `xorm:"UNIQUE(watch)"`
|
RepoID int64 `xorm:"UNIQUE(watch) INDEX"`
|
||||||
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||||
@@ -79,7 +80,7 @@ func (w *Watch) IsWatching() bool {
|
|||||||
|
|
||||||
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
|
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
|
||||||
func (w *Watch) IsWatchingAll() bool {
|
func (w *Watch) IsWatchingAll() bool {
|
||||||
return w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
|
return w.Mode != WatchModeCustom && w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watch) IsWatchingAny() bool {
|
func (w *Watch) IsWatchingAny() bool {
|
||||||
@@ -111,6 +112,9 @@ func IsWatchingRepo(ctx context.Context, userID, repoID int64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
|
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
|
||||||
|
if watch.UserID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if watch.Mode == mode {
|
if watch.Mode == mode {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -166,6 +170,9 @@ func WatchRepoAuto(ctx context.Context, doer *user_model.User, repo *Repository,
|
|||||||
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
|
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
|
||||||
return user_model.ErrBlockedUser
|
return user_model.ErrBlockedUser
|
||||||
}
|
}
|
||||||
|
if watch.Mode == WatchModeCustom { // keep the events the user picked
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return watchRepoByMode(ctx, watch, WatchModeNormal)
|
return watchRepoByMode(ctx, watch, WatchModeNormal)
|
||||||
}
|
}
|
||||||
@@ -189,7 +196,7 @@ func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if opts.Mode == WatchModeNormal {
|
if opts.Mode == WatchModeNormal || opts.Mode == WatchModeCustom {
|
||||||
_, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID).
|
_, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID).
|
||||||
Cols("include_pull_requests", "include_issues", "include_releases").
|
Cols("include_pull_requests", "include_issues", "include_releases").
|
||||||
Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases})
|
Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases})
|
||||||
@@ -247,6 +254,16 @@ func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) {
|
|||||||
// but avoids joining with `user` for performance reasons
|
// but avoids joining with `user` for performance reasons
|
||||||
// User permissions must be verified elsewhere if required
|
// User permissions must be verified elsewhere if required
|
||||||
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
|
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
|
||||||
|
return getRepoWatchersIDs(ctx, repoID, watchType, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoCustomWatchersIDs is GetRepoWatchersIDs limited to custom watches, which unlike
|
||||||
|
// watching all activity are never set up automatically (e.g. by AutoWatchNewRepos)
|
||||||
|
func GetRepoCustomWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
|
||||||
|
return getRepoWatchersIDs(ctx, repoID, watchType, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType, customOnly bool) ([]int64, error) {
|
||||||
ids := make([]int64, 0, 64)
|
ids := make([]int64, 0, 64)
|
||||||
var watchColName string
|
var watchColName string
|
||||||
switch watchType {
|
switch watchType {
|
||||||
@@ -259,12 +276,19 @@ func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType)
|
|||||||
default:
|
default:
|
||||||
panic("invalid WatchType")
|
panic("invalid WatchType")
|
||||||
}
|
}
|
||||||
return ids, db.GetEngine(ctx).Table("watch").
|
sess := db.GetEngine(ctx).Table("watch").
|
||||||
Where("watch.repo_id=?", repoID).
|
Where("watch.repo_id=?", repoID).
|
||||||
And("watch.mode<>?", WatchModeDont).
|
And("watch.mode<>?", WatchModeDont).
|
||||||
And(builder.Eq{watchColName: true}).
|
And(builder.Eq{watchColName: true})
|
||||||
Select("user_id").
|
if customOnly { // matches SelectedMode() == "custom"
|
||||||
Find(&ids)
|
sess = sess.And(builder.Or(
|
||||||
|
builder.Eq{"watch.mode": WatchModeCustom},
|
||||||
|
builder.Eq{"watch.include_pull_requests": false},
|
||||||
|
builder.Eq{"watch.include_issues": false},
|
||||||
|
builder.Eq{"watch.include_releases": false},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return ids, sess.Select("user_id").Find(&ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRepoWatchers returns range of users watching given repository.
|
// GetRepoWatchers returns range of users watching given repository.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestGetWatchers(t *testing.T) {
|
|||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
|
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
// One watchers are inactive, thus minus 1
|
// One watcher is inactive, thus minus 1
|
||||||
assert.Len(t, watches, repo.NumWatches-1)
|
assert.Len(t, watches, repo.NumWatches-1)
|
||||||
for _, watch := range watches {
|
for _, watch := range watches {
|
||||||
assert.Equal(t, repo.ID, watch.RepoID)
|
assert.Equal(t, repo.ID, watch.RepoID)
|
||||||
@@ -90,24 +90,30 @@ func TestWatchIfAuto(t *testing.T) {
|
|||||||
|
|
||||||
setting.Service.AutoWatchOnChanges = true
|
setting.Service.AutoWatchOnChanges = true
|
||||||
|
|
||||||
// Must not add watch
|
// Must not add watch (user 8 has "don't watch" set)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount)
|
assert.Len(t, watchers, prevCount)
|
||||||
|
|
||||||
// Should not add watch
|
// Should not add watch (read operation)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount)
|
assert.Len(t, watchers, prevCount)
|
||||||
|
|
||||||
// Should add watch
|
// Should add watch (write operation)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount+1)
|
assert.Len(t, watchers, prevCount+1)
|
||||||
|
|
||||||
|
// Should not add watch (system user should not be able to watch)
|
||||||
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), user_model.ActionsUserID, 1, true))
|
||||||
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, watchers, prevCount+1)
|
||||||
|
|
||||||
// Should remove watch, inhibit from adding auto
|
// Should remove watch, inhibit from adding auto
|
||||||
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
|
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
@@ -145,7 +151,7 @@ func TestWatchOptions(t *testing.T) {
|
|||||||
// repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled
|
// repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true}))
|
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true})) // custom watches were stored like this before WatchModeCustom
|
||||||
|
|
||||||
for watchType, expected := range map[repo_model.WatchType][]int64{
|
for watchType, expected := range map[repo_model.WatchType][]int64{
|
||||||
repo_model.WatchPullRequests: {1, 4, 9, 11},
|
repo_model.WatchPullRequests: {1, 4, 9, 11},
|
||||||
@@ -157,6 +163,20 @@ func TestWatchOptions(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, expected, ids, watchType)
|
assert.ElementsMatch(t, expected, ids, watchType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a custom watch picking every event differs from all activity, which can be set up automatically
|
||||||
|
customUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||||
|
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), customUser, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeCustom, WatchPullRequests: true, WatchIssues: true, WatchReleases: true}))
|
||||||
|
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), customUser, repo, true)) // must keep the custom watch
|
||||||
|
for watchType, expected := range map[repo_model.WatchType][]int64{
|
||||||
|
repo_model.WatchPullRequests: {1, 4},
|
||||||
|
repo_model.WatchIssues: {4},
|
||||||
|
repo_model.WatchReleases: {4},
|
||||||
|
} {
|
||||||
|
ids, err := repo_model.GetRepoCustomWatchersIDs(t.Context(), repo.ID, watchType)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, expected, ids, watchType)
|
||||||
|
}
|
||||||
|
|
||||||
// the options of one user must not show up for another
|
// the options of one user must not show up for another
|
||||||
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
|
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -176,5 +196,6 @@ func TestWatchSelectedMode(t *testing.T) {
|
|||||||
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
|
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
|
||||||
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
|
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
|
||||||
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode())
|
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode())
|
||||||
|
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeCustom, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
|
||||||
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
|
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -103,6 +104,10 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
|
|||||||
require.NoError(t, err, "creating fixture %s", fixturePath)
|
require.NoError(t, err, "creating fixture %s", fixturePath)
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_, err = fmt.Fprintf(out, "HTTP/1.1 %d %s\n", resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||||
|
require.NoError(t, err, "writing fixture header for %s", liveURL)
|
||||||
|
}
|
||||||
for _, name := range slices.Sorted(maps.Keys(resp.Header)) {
|
for _, name := range slices.Sorted(maps.Keys(resp.Header)) {
|
||||||
if slices.Contains(ignoredHeaders, strings.ToLower(name)) {
|
if slices.Contains(ignoredHeaders, strings.ToLower(name)) {
|
||||||
continue
|
continue
|
||||||
@@ -125,14 +130,21 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
|
|||||||
|
|
||||||
replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL)
|
replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL)
|
||||||
headers, body, _ := strings.Cut(replayed, "\n\n")
|
headers, body, _ := strings.Cut(replayed, "\n\n")
|
||||||
|
code := http.StatusOK
|
||||||
for line := range strings.SplitSeq(headers, "\n") {
|
for line := range strings.SplitSeq(headers, "\n") {
|
||||||
|
if strings.HasPrefix(line, "HTTP/") {
|
||||||
|
parts := strings.SplitN(line, " ", 3)
|
||||||
|
require.Len(t, parts, 3)
|
||||||
|
code, _ = strconv.Atoi(parts[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
name, value, ok := strings.Cut(line, ": ")
|
name, value, ok := strings.Cut(line, ": ")
|
||||||
if !ok || strings.EqualFold(name, "Content-Length") {
|
if !ok || strings.EqualFold(name, "Content-Length") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
w.Header().Set(name, value)
|
w.Header().Set(name, value)
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(code)
|
||||||
_, err = w.Write([]byte(body))
|
_, err = w.Write([]byte(body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -152,15 +152,27 @@ func TestListEmails(t *testing.T) {
|
|||||||
func TestEmailAddressValidate(t *testing.T) {
|
func TestEmailAddressValidate(t *testing.T) {
|
||||||
cases := map[string]bool{
|
cases := map[string]bool{
|
||||||
"": false,
|
"": false,
|
||||||
"root@localhost": true,
|
|
||||||
"user@[192.168.1.2]": true,
|
|
||||||
"@a": false,
|
"@a": false,
|
||||||
|
|
||||||
|
// "_" shouldn't appear in domain but can appear in hostname, since we can't stop site admins from doing so, just accept it
|
||||||
|
"root@local_host": true,
|
||||||
|
"root@localhost": true,
|
||||||
|
"root@LOCALHOST": true,
|
||||||
|
|
||||||
|
"user@[192.168.1.2]": true,
|
||||||
|
"user@[IPv6:FFff::1]": true,
|
||||||
|
|
||||||
"abc@gmail.com": true,
|
"abc@gmail.com": true,
|
||||||
|
"abc@gmail.com.": false,
|
||||||
|
"abc@gmail.com-": false,
|
||||||
"abc@gmail.com\n": false,
|
"abc@gmail.com\n": false,
|
||||||
|
"abc@gmail..com": false,
|
||||||
|
"abc@gmail com": false,
|
||||||
|
"abc@gmail*com": false,
|
||||||
|
|
||||||
"Foo <foo@bar.com>": false,
|
"Foo <foo@bar.com>": false,
|
||||||
"abc@gmail.com (x)": false,
|
"abc@gmail.com (x)": false,
|
||||||
"jürgen@example.com": false,
|
"jürgen@example.com": false, // utf8 address is not supported yet
|
||||||
"a@foo_bar.com": false,
|
|
||||||
}
|
}
|
||||||
for tc, isValid := range cases {
|
for tc, isValid := range cases {
|
||||||
t.Run(tc, func(t *testing.T) {
|
t.Run(tc, func(t *testing.T) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
@@ -32,6 +33,9 @@ func IsFollowing(ctx context.Context, userID, followID int64) bool {
|
|||||||
|
|
||||||
// FollowUser marks someone be another's follower.
|
// FollowUser marks someone be another's follower.
|
||||||
func FollowUser(ctx context.Context, user, follow *User) (err error) {
|
func FollowUser(ctx context.Context, user, follow *User) (err error) {
|
||||||
|
if !follow.IsFollowable() {
|
||||||
|
return util.NewInvalidArgumentErrorf("user %d can not be followed", follow.ID)
|
||||||
|
}
|
||||||
if user.ID == follow.ID || IsFollowing(ctx, user.ID, follow.ID) {
|
if user.ID == follow.ID || IsFollowing(ctx, user.ID, follow.ID) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-6
@@ -68,13 +68,20 @@ const (
|
|||||||
UserTypeRemoteUser // 5
|
UserTypeRemoteUser // 5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AdminListedUserTypes HINT: USER-TYPE-GLOSSARY: administrators list every user type except organizations
|
||||||
|
var AdminListedUserTypes = []UserType{UserTypeIndividual, UserTypeUserReserved, UserTypeBot, UserTypeRemoteUser}
|
||||||
|
|
||||||
// DisplayName returns the English name of the user type for logs and the CLI, the UI translates "concept_user_*" instead
|
// DisplayName returns the English name of the user type for logs and the CLI, the UI translates "concept_user_*" instead
|
||||||
func (t UserType) DisplayName() string {
|
func (t UserType) DisplayName() string {
|
||||||
switch t {
|
switch t {
|
||||||
case UserTypeOrganization, UserTypeOrganizationReserved:
|
case UserTypeOrganization, UserTypeOrganizationReserved:
|
||||||
return "Organization"
|
return "Organization"
|
||||||
|
case UserTypeUserReserved:
|
||||||
|
return "Reserved"
|
||||||
case UserTypeBot:
|
case UserTypeBot:
|
||||||
return "Bot"
|
return "Bot"
|
||||||
|
case UserTypeRemoteUser:
|
||||||
|
return "Remote"
|
||||||
default:
|
default:
|
||||||
return "User"
|
return "User"
|
||||||
}
|
}
|
||||||
@@ -250,10 +257,10 @@ func (u *User) GetEmail() string {
|
|||||||
return u.Email
|
return u.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllUsers returns a slice of all individual users found in DB.
|
// GetAllUsersForAdminListing returns a slice of all users of AdminListedUserTypes found in DB.
|
||||||
func GetAllUsers(ctx context.Context) ([]*User, error) {
|
func GetAllUsersForAdminListing(ctx context.Context) (users []*User, err error) {
|
||||||
users := make([]*User, 0)
|
err = db.GetEngine(ctx).OrderBy("id").In("type", AdminListedUserTypes).Find(&users)
|
||||||
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users)
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsLocal returns true if user login type is LoginPlain.
|
// IsLocal returns true if user login type is LoginPlain.
|
||||||
@@ -440,6 +447,18 @@ func (u *User) IsIndividual() bool {
|
|||||||
return u.ID > 0 && u.Type == UserTypeIndividual
|
return u.ID > 0 && u.Type == UserTypeIndividual
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsFollowable returns whether other users can follow this user (individuals, bots and organizations)
|
||||||
|
func (u *User) IsFollowable() bool {
|
||||||
|
if u.ID <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch u.Type {
|
||||||
|
case UserTypeIndividual, UserTypeBot, UserTypeOrganization:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// IsTypeBot returns whether the user is of type bot
|
// IsTypeBot returns whether the user is of type bot
|
||||||
func (u *User) IsTypeBot() bool {
|
func (u *User) IsTypeBot() bool {
|
||||||
return u.Type == UserTypeBot
|
return u.Type == UserTypeBot
|
||||||
@@ -1021,7 +1040,8 @@ func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, er
|
|||||||
Find(&users)
|
Find(&users)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByID returns the user object by given ID if exists.
|
// GetUserByID returns the user object by given ID if exists. TODO: for most cases, this function should be avoided.
|
||||||
|
// Use correct GetXxxUser functions instead to handle user types & states correctly.
|
||||||
func GetUserByID(ctx context.Context, id int64) (*User, error) {
|
func GetUserByID(ctx context.Context, id int64) (*User, error) {
|
||||||
u := new(User)
|
u := new(User)
|
||||||
has, err := db.GetEngine(ctx).ID(id).Get(u)
|
has, err := db.GetEngine(ctx).ID(id).Get(u)
|
||||||
@@ -1033,7 +1053,8 @@ func GetUserByID(ctx context.Context, id int64) (*User, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByIDs returns the user objects by given IDs if exists.
|
// GetUserByIDs returns the user objects by given IDs if exists. TODO: for most cases, this function should be avoided.
|
||||||
|
// Use correct GetXxxUser functions instead to handle user types & states correctly.
|
||||||
func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -1046,6 +1067,13 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
|||||||
return users, err
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HINT: USER-TYPE-GLOSSARY: Glossaries for XxxUser:
|
||||||
|
// * Possible: The user may or may not exist in the database, but we can still return a valid user object (e.g. Ghost user) for the given ID.
|
||||||
|
// * Individual: The user is a real person, not an organization or a bot.
|
||||||
|
// * Doer: The user is the one performing an action
|
||||||
|
// * Valid: The user is an active user and not prohibited from signing in
|
||||||
|
// * System: Negative ID (e.g. Ghost, Actions, DeployKey, etc.) and not a real person, Ghost is not included if there is no "Possible" keyword
|
||||||
|
|
||||||
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
|
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
|
||||||
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
|
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
|
||||||
if id < 0 {
|
if id < 0 {
|
||||||
@@ -1082,6 +1110,23 @@ func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
|||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetValidDoerUserByID(ctx context.Context, id int64) (*User, error) {
|
||||||
|
u, err := GetUserByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !u.IsIndividual() && !u.IsTypeBot() {
|
||||||
|
return nil, util.NewNotExistErrorf("user %d is not a doer", id)
|
||||||
|
}
|
||||||
|
if !u.IsActive {
|
||||||
|
return nil, util.NewPermissionDeniedErrorf("user %d is inactive", id)
|
||||||
|
}
|
||||||
|
if u.ProhibitLogin {
|
||||||
|
return nil, util.NewPermissionDeniedErrorf("user %d is prohibited from login", id)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
func getUserByNameWithTypes(ctx context.Context, name string, types ...UserType) (*User, error) {
|
func getUserByNameWithTypes(ctx context.Context, name string, types ...UserType) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
sess := db.GetEngine(ctx).Where(builder.Eq{"lower_name": strings.ToLower(name)})
|
sess := db.GetEngine(ctx).Where(builder.Eq{"lower_name": strings.ToLower(name)})
|
||||||
@@ -1201,6 +1246,9 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro
|
|||||||
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
|
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
emailLower := strings.ToLower(email)
|
emailLower := strings.ToLower(email)
|
||||||
|
if emailLower == "" {
|
||||||
|
continue // a commit without an author email names no user
|
||||||
|
}
|
||||||
needCheckEmails.Add(emailLower)
|
needCheckEmails.Add(emailLower)
|
||||||
if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
|
if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
|
||||||
name, id := parseLocalPartToNameID(localPart)
|
name, id := parseLocalPartToNameID(localPart)
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ func TestUserEmails(t *testing.T) {
|
|||||||
setting.Service.NoReplyAddress = "example.com"
|
setting.Service.NoReplyAddress = "example.com"
|
||||||
testGetUserByEmail(t, "user1-2@example.COM", 1)
|
testGetUserByEmail(t, "user1-2@example.COM", 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyEmail", func(t *testing.T) {
|
||||||
|
// A user whose email address is empty must not own commits that have no author email.
|
||||||
|
require.NoError(t, db.Insert(t.Context(), &user_model.EmailAddress{UID: 2, Email: "", LowerEmail: "", IsActivated: true}))
|
||||||
|
testGetUserByEmail(t, "", 0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
t.Run("GetUserByEmail", func(t *testing.T) {
|
t.Run("GetUserByEmail", func(t *testing.T) {
|
||||||
testGetUserByEmail := func(t *testing.T, email string, uid int64) {
|
testGetUserByEmail := func(t *testing.T, email string, uid int64) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
git_model "gitea.dev/models/git"
|
git_model "gitea.dev/models/git"
|
||||||
repo_model "gitea.dev/models/repo"
|
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,19 +35,11 @@ func GetCommitActionsStatusMap(ctx context.Context, statuses []*git_model.Commit
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
statusByJobID := make(map[int64]*git_model.CommitStatus)
|
statusByJobID := make(map[int64]*git_model.CommitStatus)
|
||||||
repoByID := make(map[int64]*repo_model.Repository)
|
|
||||||
for _, status := range statuses {
|
for _, status := range statuses {
|
||||||
if status == nil || status.TargetURL == "" {
|
if status == nil || status.TargetURL == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if status.Repo == nil {
|
if _, jobID, ok := status.ParseGiteaActionsTargetURL(ctx); ok {
|
||||||
status.Repo = repoByID[status.RepoID]
|
|
||||||
}
|
|
||||||
// ParseGiteaActionsTargetURL lazy-loads status.Repo on miss; cache the
|
|
||||||
// outcome so later entries with the same RepoID skip that load.
|
|
||||||
_, jobID, ok := status.ParseGiteaActionsTargetURL(ctx)
|
|
||||||
repoByID[status.RepoID] = status.Repo
|
|
||||||
if ok {
|
|
||||||
statusByJobID[jobID] = status
|
statusByJobID[jobID] = status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package jobparser
|
package jobparser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -65,44 +64,31 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
|
|||||||
m := map[string]*Job{
|
m := map[string]*Job{
|
||||||
id: job,
|
id: job,
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
out, err := marshalYAML(m)
|
||||||
encoder := yaml.NewEncoder(&buf)
|
if err != nil {
|
||||||
encoder.SetIndent(2)
|
|
||||||
if err := encoder.Encode(m); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
encoder.Close()
|
|
||||||
|
|
||||||
node := yaml.Node{}
|
node := yaml.Node{}
|
||||||
if err := yaml.Unmarshal(buf.Bytes(), &node); err != nil {
|
if err := yaml.Unmarshal(out, &node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
|
if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
|
||||||
return fmt.Errorf("can not set job: %s", buf.String())
|
return fmt.Errorf("can not set job: %s", out)
|
||||||
}
|
}
|
||||||
w.RawJobs = *node.Content[0]
|
w.RawJobs = *node.Content[0]
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *SingleWorkflow) Marshal() ([]byte, error) {
|
func (w *SingleWorkflow) Marshal() ([]byte, error) {
|
||||||
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
|
|
||||||
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
|
|
||||||
// `run:` step that begins with blank lines) with a wrong explicit indentation
|
|
||||||
// indicator (`run: |4`) that then fails to re-parse, which silently strands
|
|
||||||
// the job during concurrency evaluation. Keeping both encoders at indent 2
|
|
||||||
// makes the serialized single workflow round-trip.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
enc := yaml.NewEncoder(&buf)
|
|
||||||
enc.SetIndent(2)
|
|
||||||
payload := *w
|
payload := *w
|
||||||
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
|
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
|
||||||
if err := enc.Encode(&payload); err != nil {
|
return marshalYAML(&payload)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
if err := enc.Close(); err != nil {
|
|
||||||
return nil, err
|
// marshalYAML never wraps lines, go-yaml wraps more-indented lines of folded scalars until https://github.com/yaml/go-yaml/pull/441 is released.
|
||||||
}
|
func marshalYAML(v any) ([]byte, error) {
|
||||||
return buf.Bytes(), nil
|
return yaml.Dump(v, yaml.WithV3Defaults(), yaml.WithIndent(2), yaml.WithLineWidth(-1))
|
||||||
}
|
}
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
@@ -188,23 +174,12 @@ func (j *Job) DisplayName() string {
|
|||||||
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
|
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockSafeString works around https://github.com/yaml/go-yaml/issues/399, quoting a value whose
|
|
||||||
// leading newline would cost a literal block scalar its indentation indicator.
|
|
||||||
type BlockSafeString string
|
|
||||||
|
|
||||||
func (s BlockSafeString) MarshalYAML() (any, error) {
|
|
||||||
if !strings.HasPrefix(string(s), "\n") {
|
|
||||||
return string(s), nil
|
|
||||||
}
|
|
||||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle, Value: string(s)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step struct {
|
type Step struct {
|
||||||
ID string `yaml:"id,omitempty"`
|
ID string `yaml:"id,omitempty"`
|
||||||
If yaml.Node `yaml:"if,omitempty"`
|
If yaml.Node `yaml:"if,omitempty"`
|
||||||
Name BlockSafeString `yaml:"name,omitempty"`
|
Name string `yaml:"name,omitempty"`
|
||||||
Uses string `yaml:"uses,omitempty"`
|
Uses string `yaml:"uses,omitempty"`
|
||||||
Run BlockSafeString `yaml:"run,omitempty"`
|
Run string `yaml:"run,omitempty"`
|
||||||
WorkingDirectory string `yaml:"working-directory,omitempty"`
|
WorkingDirectory string `yaml:"working-directory,omitempty"`
|
||||||
Shell string `yaml:"shell,omitempty"`
|
Shell string `yaml:"shell,omitempty"`
|
||||||
Env yaml.Node `yaml:"env,omitempty"`
|
Env yaml.Node `yaml:"env,omitempty"`
|
||||||
@@ -232,9 +207,9 @@ func (s *Step) String() string {
|
|||||||
}
|
}
|
||||||
return (&model.Step{
|
return (&model.Step{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
Name: string(s.Name),
|
Name: s.Name,
|
||||||
Uses: s.Uses,
|
Uses: s.Uses,
|
||||||
Run: string(s.Run),
|
Run: s.Run,
|
||||||
}).String()
|
}).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
_, origJob := sws[0].Job()
|
_, origJob := sws[0].Job()
|
||||||
require.Len(t, origJob.Steps, 1)
|
require.Len(t, origJob.Steps, 1)
|
||||||
const wantRun = "\n\necho start\necho done\n"
|
const wantRun = "\n\necho start\necho done\n"
|
||||||
require.Equal(t, wantRun, string(origJob.Steps[0].Run))
|
require.Equal(t, wantRun, origJob.Steps[0].Run)
|
||||||
|
|
||||||
payload, err := sws[0].Marshal()
|
payload, err := sws[0].Marshal()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
// the round-trip must preserve the run block byte-for-byte
|
// the round-trip must preserve the run block byte-for-byte
|
||||||
_, gotJob := roundTripped[0].Job()
|
_, gotJob := roundTripped[0].Job()
|
||||||
require.Len(t, gotJob.Steps, 1)
|
require.Len(t, gotJob.Steps, 1)
|
||||||
require.Equal(t, wantRun, string(gotJob.Steps[0].Run), "round-trip must preserve run content; got payload:\n%s", payload)
|
require.Equal(t, wantRun, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node.
|
// Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node.
|
||||||
@@ -104,3 +104,37 @@ jobs:
|
|||||||
require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload)
|
require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folded (`>`) scalars in with/env used to gain blank lines and have spaces turned into newlines
|
||||||
|
// on every Marshal -> Parse cycle.
|
||||||
|
func TestSingleWorkflowRoundTripFoldedScalar(t *testing.T) {
|
||||||
|
const wf = `name: demo
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: ./.github/actions/build
|
||||||
|
with:
|
||||||
|
cfg: >
|
||||||
|
begin
|
||||||
|
first "indented" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,
|
||||||
|
second "indented" line
|
||||||
|
end
|
||||||
|
`
|
||||||
|
const want = "begin\n first \"indented\" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,\n second \"indented\" line\nend\n"
|
||||||
|
sws, err := Parse([]byte(wf))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, sws, 1)
|
||||||
|
for range 3 {
|
||||||
|
payload, err := sws[0].Marshal()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sws, err = Parse(payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, sws, 1)
|
||||||
|
}
|
||||||
|
_, job := sws[0].Job()
|
||||||
|
var with map[string]string
|
||||||
|
require.NoError(t, job.Steps[0].With.Decode(&with))
|
||||||
|
require.Equal(t, want, with["cfg"])
|
||||||
|
}
|
||||||
@@ -7,5 +7,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: extract_tag
|
- id: extract_tag
|
||||||
name: "\nExtract tag for variant"
|
name: |-
|
||||||
run: "\necho Test\n"
|
|
||||||
|
Extract tag for variant
|
||||||
|
run: |
|
||||||
|
|
||||||
|
echo Test
|
||||||
@@ -532,7 +532,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
if matchActivityType(val, actions...) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -575,7 +575,7 @@ func matchPullRequestEvent(ctx context.Context, gitRepo *git.Repository, commit
|
|||||||
}
|
}
|
||||||
log.Trace("matching pull_request %s with %v", action, vals)
|
log.Trace("matching pull_request %s with %v", action, vals)
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
if matchActivityType(val, string(action)) {
|
||||||
activityTypeMatched = true
|
activityTypeMatched = true
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
@@ -678,7 +678,7 @@ func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *j
|
|||||||
// NONE
|
// NONE
|
||||||
|
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) {
|
if matchActivityType(val, string(issueCommentPayload.Action)) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -718,7 +718,7 @@ func matchPullRequestReviewEvent(prPayload *api.PullRequestPayload, evt *jobpars
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
if matchActivityType(val, actions...) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -758,7 +758,7 @@ func matchPullRequestReviewCommentEvent(prPayload *api.PullRequestPayload, evt *
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
if matchActivityType(val, actions...) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -795,7 +795,7 @@ func matchReleaseEvent(payload *api.ReleasePayload, evt *jobparser.Event) bool {
|
|||||||
action = "edited"
|
action = "edited"
|
||||||
}
|
}
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
if matchActivityType(val, string(action)) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -832,7 +832,7 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
|
|||||||
action = "published"
|
action = "published"
|
||||||
}
|
}
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
if matchActivityType(val, string(action)) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -857,7 +857,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
|
|||||||
case "types":
|
case "types":
|
||||||
action := payload.Action
|
action := payload.Action
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(action) {
|
if matchActivityType(val, action) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -893,3 +893,13 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
|
|||||||
}
|
}
|
||||||
return matchTimes == len(evt.Acts())
|
return matchTimes == len(evt.Acts())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchActivityType treats an invalid user-provided pattern as no match instead of panicking
|
||||||
|
func matchActivityType(pattern string, actions ...string) bool {
|
||||||
|
g, err := glob.Compile(pattern, '/')
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("invalid activity type pattern %q: %v", pattern, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(actions, g.Match)
|
||||||
|
}
|
||||||
@@ -205,6 +205,13 @@ func TestDetectMatched(t *testing.T) {
|
|||||||
yamlOn: "on:\n release:\n types: [published]",
|
yamlOn: "on:\n release:\n types: [published]",
|
||||||
expected: detectMatched,
|
expected: detectMatched,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: "HookEventRelease(release) doesn't match an invalid activity type pattern",
|
||||||
|
triggedEvent: webhook_module.HookEventRelease,
|
||||||
|
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
|
||||||
|
yamlOn: "on:\n release:\n types: [\"[\"]",
|
||||||
|
expected: detectNotApplicable,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
|
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
|
||||||
triggedEvent: webhook_module.HookEventPackage,
|
triggedEvent: webhook_module.HookEventPackage,
|
||||||
|
|||||||
@@ -102,11 +102,11 @@ func NewEmbeddedFS(data []byte) fs.ReadDirFS {
|
|||||||
efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)}
|
efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)}
|
||||||
efs.meta = sync.OnceValue(func() *EmbeddedMeta {
|
efs.meta = sync.OnceValue(func() *EmbeddedMeta {
|
||||||
var meta EmbeddedMeta
|
var meta EmbeddedMeta
|
||||||
p := bytes.LastIndexByte(data, '\n')
|
_, metaJSON, ok := bytes.CutLast(data, []byte{'\n'})
|
||||||
if p < 0 {
|
if !ok {
|
||||||
return &meta
|
return &meta
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data[p+1:], &meta); err != nil {
|
if err := json.Unmarshal(metaJSON, &meta); err != nil {
|
||||||
panic("embedded file is not valid")
|
panic("embedded file is not valid")
|
||||||
}
|
}
|
||||||
return &meta
|
return &meta
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ func Init() {
|
|||||||
RPID: setting.AppDomain,
|
RPID: setting.AppDomain,
|
||||||
RPOrigins: []string{appURL},
|
RPOrigins: []string{appURL},
|
||||||
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
||||||
|
// WebKit (Safari on iOS and macOS) returns an unsolicited "appid" client
|
||||||
|
// extension output even when the relying party did not request it. Since the
|
||||||
|
// webauthn library upgrade to v0.18, the default policy fails such ceremonies
|
||||||
|
// ('Client returned the appid extension output which was not requested'), breaking
|
||||||
|
// every passkey and security-key login from Safari on iOS. Ignore unsolicited
|
||||||
|
// extension outputs, the same policy other relying parties adopted for the
|
||||||
|
// same WebKit behavior.
|
||||||
|
ExtensionsUnsolicitedOutputPolicy: protocol.UnsolicitedOutputPolicyIgnore,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,4 +23,5 @@ func TestInit(t *testing.T) {
|
|||||||
assert.Equal(t, setting.AppDomain, WebAuthn.Config.RPID)
|
assert.Equal(t, setting.AppDomain, WebAuthn.Config.RPID)
|
||||||
assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName)
|
assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName)
|
||||||
assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins)
|
assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins)
|
||||||
|
assert.Equal(t, protocol.UnsolicitedOutputPolicyIgnore, WebAuthn.Config.ExtensionsUnsolicitedOutputPolicy)
|
||||||
}
|
}
|
||||||
@@ -8,5 +8,6 @@ const (
|
|||||||
EmailAvatarLink = "email_avatar_link"
|
EmailAvatarLink = "email_avatar_link"
|
||||||
UserEmailAddresses = "user_email_addresses"
|
UserEmailAddresses = "user_email_addresses"
|
||||||
GPGKeyWithSubKeys = "gpg_key_with_subkeys"
|
GPGKeyWithSubKeys = "gpg_key_with_subkeys"
|
||||||
|
Repository = "repository"
|
||||||
RepoUserPermission = "repo_user_permission"
|
RepoUserPermission = "repo_user_permission"
|
||||||
)
|
)
|
||||||
@@ -52,7 +52,7 @@ func Lookup(label string) (e encoding.Encoding, name string) {
|
|||||||
|
|
||||||
type ConvertOpts struct {
|
type ConvertOpts struct {
|
||||||
KeepBOM bool
|
KeepBOM bool
|
||||||
ErrorReplacement []byte
|
ErrorReplacement []byte // leave it empty to remove the invalid bytes
|
||||||
ErrorReturnOrigin bool
|
ErrorReturnOrigin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,12 @@ func ToUTF8DropErrors(content []byte) []byte {
|
|||||||
func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
||||||
charsetLabel, _ := DetectEncoding(content)
|
charsetLabel, _ := DetectEncoding(content)
|
||||||
if charsetLabel == "UTF-8" {
|
if charsetLabel == "UTF-8" {
|
||||||
return maybeRemoveBOM(content, opts)
|
// even if the detected charset is UTF-8, the content may contain invalid UTF-8 sequences, so need to convert it to valid UTF-8
|
||||||
|
content = maybeRemoveBOM(content, opts)
|
||||||
|
if opts.ErrorReturnOrigin {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
return bytes.ToValidUTF8(content, opts.ErrorReplacement)
|
||||||
}
|
}
|
||||||
|
|
||||||
encoding, _ := Lookup(charsetLabel)
|
encoding, _ := Lookup(charsetLabel)
|
||||||
@@ -123,11 +128,7 @@ func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
|||||||
if opts.ErrorReturnOrigin {
|
if opts.ErrorReturnOrigin {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
if opts.ErrorReplacement == nil {
|
|
||||||
decoded = append(decoded, content[idx+n])
|
|
||||||
} else {
|
|
||||||
decoded = append(decoded, opts.ErrorReplacement...)
|
decoded = append(decoded, opts.ErrorReplacement...)
|
||||||
}
|
|
||||||
idx += n + 1
|
idx += n + 1
|
||||||
}
|
}
|
||||||
return maybeRemoveBOM(decoded, opts)
|
return maybeRemoveBOM(decoded, opts)
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ func TestToUTF8DropErrors(t *testing.T) {
|
|||||||
|
|
||||||
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00})
|
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00})
|
||||||
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
|
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
|
||||||
|
|
||||||
|
assert.Equal(t, "caf ", string(ToUTF8DropErrors([]byte("caf\xe9"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectEncoding(t *testing.T) {
|
func TestDetectEncoding(t *testing.T) {
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/translation"
|
"gitea.dev/modules/translation"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type htmlChunkReader struct {
|
type htmlChunkReader struct {
|
||||||
@@ -30,6 +32,10 @@ type escapeStreamer struct {
|
|||||||
ambiguousTables []*AmbiguousTable
|
ambiguousTables []*AmbiguousTable
|
||||||
allowed map[rune]bool
|
allowed map[rune]bool
|
||||||
|
|
||||||
|
tagPartial []byte // partial tag content, used to detect if we are in some tags
|
||||||
|
|
||||||
|
inTagMath bool // MathML operators like U+2212 are intended and wrapping them breaks the math layout
|
||||||
|
|
||||||
out io.Writer
|
out io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +68,7 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
|
|||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
if partInTag[i] {
|
if partInTag[i] {
|
||||||
lastIsTag = true
|
lastIsTag = true
|
||||||
|
es.trackHtmlTag(part)
|
||||||
if _, err := out.Write(part); err != nil {
|
if _, err := out.Write(part); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -75,7 +82,11 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err = es.detectAndWriteRunes(part); err != nil {
|
if es.inTagMath {
|
||||||
|
if _, err := out.Write(part); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if err = es.detectAndWriteRunes(part); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +94,34 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trackHtmlTag receives tag parts, a tag might be split into multiple parts
|
||||||
|
func (e *escapeStreamer) trackHtmlTag(part []byte) {
|
||||||
|
const maxHeadLen = 100 // only read the first N bytes of the tag for detection purpose
|
||||||
|
if part[0] == '<' {
|
||||||
|
// start a new tag
|
||||||
|
e.tagPartial = e.tagPartial[:0]
|
||||||
|
}
|
||||||
|
if len(e.tagPartial) >= maxHeadLen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.tagPartial = append(e.tagPartial, part[:min(len(part), maxHeadLen-len(e.tagPartial))]...)
|
||||||
|
|
||||||
|
isTag := func(prefix string) bool {
|
||||||
|
if len(e.tagPartial) < len(prefix)+1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !util.AsciiEqualFold(e.tagPartial[:len(prefix)], []byte(prefix)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.IndexByte(" \t\n\r\f>", e.tagPartial[len(prefix)]) != -1
|
||||||
|
}
|
||||||
|
if isTag("<math") {
|
||||||
|
e.inTagMath = true
|
||||||
|
} else if isTag("</math") {
|
||||||
|
e.inTagMath = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *escapeStreamer) trimAndWriteBom(part []byte) ([]byte, error) {
|
func (e *escapeStreamer) trimAndWriteBom(part []byte) ([]byte, error) {
|
||||||
remaining, ok := bytes.CutPrefix(part, globalVars().utf8Bom)
|
remaining, ok := bytes.CutPrefix(part, globalVars().utf8Bom)
|
||||||
if ok {
|
if ok {
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ then resh (ר), and finally heh (ה) (which should appear leftmost).`,
|
|||||||
result: `O<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:𝐾 [U+1D43E],K [U+004B]"><span class="char">𝐾</span></span>`,
|
result: `O<span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:𝐾 [U+1D43E],K [U+004B]"><span class="char">𝐾</span></span>`,
|
||||||
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
|
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "ambiguous in math",
|
||||||
|
text: "<math><mo>−</mo><mi>b</mi></math> −",
|
||||||
|
result: `<math><mo>−</mo><mi>b</mi></math> <span class="ambiguous-code-point" data-tooltip-content="repo.ambiguous_character:− [U+2212],- [U+002D]"><span class="char">−</span></span>`,
|
||||||
|
status: EscapeStatus{Escaped: true, HasAmbiguous: true},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEscapeControlReader(t *testing.T) {
|
func TestEscapeControlReader(t *testing.T) {
|
||||||
@@ -156,6 +162,24 @@ func TestEscapeControlReader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTrackHtmlTag(t *testing.T) {
|
||||||
|
e := &escapeStreamer{}
|
||||||
|
for _, tt := range []struct {
|
||||||
|
parts []string
|
||||||
|
inMath bool
|
||||||
|
}{
|
||||||
|
{[]string{"<ma", `TH display="block">`}, true},
|
||||||
|
{[]string{"<mo>"}, true},
|
||||||
|
{[]string{"</MA", "th>"}, false},
|
||||||
|
{[]string{"<mathx>"}, false},
|
||||||
|
} {
|
||||||
|
for _, part := range tt.parts {
|
||||||
|
e.trackHtmlTag([]byte(part))
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.inMath, e.inTagMath, "%v", tt.parts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSettingAmbiguousUnicodeDetection(t *testing.T) {
|
func TestSettingAmbiguousUnicodeDetection(t *testing.T) {
|
||||||
defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)()
|
defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, true)()
|
||||||
_, out := EscapeControlHTML("a test", &translation.MockLocale{})
|
_, out := EscapeControlHTML("a test", &translation.MockLocale{})
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func NewWebhookPolicy() *policy.Policy {
|
|||||||
}
|
}
|
||||||
p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode),
|
p = policy.NewPolicy("webhook", policyMode(setting.Security.EgressMode),
|
||||||
policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"),
|
policy.WithAllow(setting.Webhook.AllowedHostList, "security.ALLOWED_HOST_LIST"),
|
||||||
|
policy.WithLocalNeedsIPAllow(),
|
||||||
policy.WithProxy(selectProxy))
|
policy.WithProxy(selectProxy))
|
||||||
|
|
||||||
return p
|
return p
|
||||||
@@ -106,6 +107,7 @@ func NewWebhookPolicy() *policy.Policy {
|
|||||||
func NewSecurityPolicy(usage string) *policy.Policy {
|
func NewSecurityPolicy(usage string) *policy.Policy {
|
||||||
return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode),
|
return policy.NewPolicy(usage, policyMode(setting.Security.EgressMode),
|
||||||
policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"),
|
policy.WithAllow(setting.Security.AllowedHostList, "security.ALLOWED_HOST_LIST"),
|
||||||
|
policy.WithLocalNeedsIPAllow(),
|
||||||
policy.WithProxy(proxy.Proxy()))
|
policy.WithProxy(proxy.Proxy()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
package egress
|
package egress
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modules/egress/policy"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
"gitea.dev/modules/test"
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
@@ -70,6 +73,27 @@ func TestWebhookPolicyProxy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebhookPolicyNeedsIPAllow(t *testing.T) {
|
||||||
|
defer test.MockVariableValue(&setting.Webhook.AllowedHostList, "localhost")()
|
||||||
|
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = ln.Close() })
|
||||||
|
dial := func() error {
|
||||||
|
tcpAddr, ok := ln.Addr().(*net.TCPAddr)
|
||||||
|
require.True(t, ok)
|
||||||
|
target := net.JoinHostPort("localhost", strconv.Itoa(tcpAddr.Port))
|
||||||
|
conn, err := NewWebhookPolicy().NewDialContext()(t.Context(), "tcp", target)
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
assert.ErrorIs(t, dial(), policy.ErrDenied) // a host name entry doesn't cover the loopback address
|
||||||
|
setting.Webhook.AllowedHostList = "loopback"
|
||||||
|
assert.NoError(t, dial()) // an IP entry does
|
||||||
|
}
|
||||||
|
|
||||||
func TestSecurityPolicy(t *testing.T) {
|
func TestSecurityPolicy(t *testing.T) {
|
||||||
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
|
defer test.MockVariableValue(&setting.Security.AllowedHostList, "avatars.example.com")()
|
||||||
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
|
defer test.MockVariableValue(&setting.Security.EgressMode, "lax")()
|
||||||
|
|||||||
@@ -412,36 +412,42 @@ var cgnatRange = netip.MustParsePrefix("100.64.0.0/10") // RFC 6598
|
|||||||
var reservedRanges = func() (ranges []netip.Prefix) {
|
var reservedRanges = func() (ranges []netip.Prefix) {
|
||||||
for _, cidr := range []string{
|
for _, cidr := range []string{
|
||||||
"0.0.0.0/8", // "this network"
|
"0.0.0.0/8", // "this network"
|
||||||
"100.100.100.200/32", // Alibaba Cloud metadata
|
|
||||||
"168.63.129.16/32", // Azure WireServer
|
"168.63.129.16/32", // Azure WireServer
|
||||||
"169.254.0.0/16", // link-local, cloud metadata endpoints
|
|
||||||
"192.0.0.0/24", // IETF protocol assignments
|
|
||||||
"192.0.2.0/24", // TEST-NET-1
|
|
||||||
"192.31.196.0/24", // AS112
|
|
||||||
"192.52.193.0/24", // AMT
|
|
||||||
"192.88.99.0/24", // 6to4 relay anycast
|
"192.88.99.0/24", // 6to4 relay anycast
|
||||||
"192.175.48.0/24", // AS112
|
|
||||||
"198.18.0.0/15", // benchmarking
|
|
||||||
"198.51.100.0/24", // TEST-NET-2
|
|
||||||
"203.0.113.0/24", // TEST-NET-3
|
|
||||||
"224.0.0.0/4", // multicast
|
"224.0.0.0/4", // multicast
|
||||||
"240.0.0.0/4", // reserved, incl. limited broadcast
|
"240.0.0.0/4", // reserved, incl. limited broadcast
|
||||||
"::/96", // IPv4-compatible, embeds IPv4
|
"::/96", // IPv4-compatible, embeds IPv4
|
||||||
"::ffff:0:0:0/96", // IPv4-translated, embeds IPv4
|
"::ffff:0:0:0/96", // IPv4-translated, embeds IPv4
|
||||||
"64:ff9b::/96", // wkp NAT64
|
"64:ff9b::/96", // wkp NAT64
|
||||||
"64:ff9b:1::/48", // local-use NAT64
|
"64:ff9b:1::/48", // local-use NAT64
|
||||||
|
"2001::/32", // Teredo, embeds IPv4
|
||||||
|
"2002::/16", // 6to4, embeds IPv4
|
||||||
|
"ff00::/8", // multicast
|
||||||
|
} {
|
||||||
|
ranges = append(ranges, netip.MustParsePrefix(cidr))
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}()
|
||||||
|
|
||||||
|
// restrictedRanges are dialable if they have been explicitly allowed.
|
||||||
|
var restrictedRanges = func() (ranges []netip.Prefix) {
|
||||||
|
for _, cidr := range []string{
|
||||||
|
"192.0.0.0/24", // IETF protocol assignments
|
||||||
|
"192.0.2.0/24", // TEST-NET-1
|
||||||
|
"192.31.196.0/24", // AS112
|
||||||
|
"192.52.193.0/24", // AMT
|
||||||
|
"192.175.48.0/24", // AS112
|
||||||
|
"198.18.0.0/15", // benchmarking
|
||||||
|
"198.51.100.0/24", // TEST-NET-2
|
||||||
|
"203.0.113.0/24", // TEST-NET-3
|
||||||
"100::/64", // discard-only
|
"100::/64", // discard-only
|
||||||
"100:0:0:1::/64", // dummy
|
"100:0:0:1::/64", // dummy
|
||||||
"2001::/23", // IETF protocol assignments, incl. Teredo and ORCHID
|
"2001::/23", // IETF protocol assignments
|
||||||
"2001:db8::/32", // documentation
|
"2001:db8::/32", // documentation
|
||||||
"2002::/16", // 6to4, embeds IPv4
|
|
||||||
"2620:4f:8000::/48", // AS112
|
"2620:4f:8000::/48", // AS112
|
||||||
"3fff::/20", // documentation
|
"3fff::/20", // documentation
|
||||||
"5f00::/16", // SRv6 SIDs
|
"5f00::/16", // SRv6 SIDs
|
||||||
"fd00:ec2::254/128", // AWS IMDS
|
|
||||||
"fe80::/10", // link-local
|
|
||||||
"fec0::/10", // site-local
|
"fec0::/10", // site-local
|
||||||
"ff00::/8", // multicast
|
|
||||||
} {
|
} {
|
||||||
ranges = append(ranges, netip.MustParsePrefix(cidr))
|
ranges = append(ranges, netip.MustParsePrefix(cidr))
|
||||||
}
|
}
|
||||||
@@ -451,10 +457,14 @@ var reservedRanges = func() (ranges []netip.Prefix) {
|
|||||||
// classifyAddr reports the class of a canonical address.
|
// classifyAddr reports the class of a canonical address.
|
||||||
func classifyAddr(ip netip.Addr) addrClass {
|
func classifyAddr(ip netip.Addr) addrClass {
|
||||||
switch {
|
switch {
|
||||||
case ip.Zone() != "" || !ip.IsLoopback() && slices.ContainsFunc(reservedRanges, func(p netip.Prefix) bool { return p.Contains(ip) }):
|
case ip.Zone() != "" || !ip.IsLoopback() && inRange(reservedRanges, ip):
|
||||||
return classReserved
|
return classReserved
|
||||||
case ip.IsPrivate() || ip.IsLoopback() || cgnatRange.Contains(ip):
|
case ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || cgnatRange.Contains(ip) || inRange(restrictedRanges, ip):
|
||||||
return classRestricted
|
return classRestricted
|
||||||
}
|
}
|
||||||
return classPublic
|
return classPublic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inRange(p []netip.Prefix, ip netip.Addr) bool {
|
||||||
|
return slices.ContainsFunc(p, func(p netip.Prefix) bool { return p.Contains(ip) })
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ func WithBlock(hostList, key string) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithLocalNeedsIPAllow requires private, loopback and CGNAT targets to match an IP allow entry (CIDR or named range), a host name match is not enough.
|
// WithLocalNeedsIPAllow requires non-public targets (private, loopback, link-local, CGNAT and special-use ranges) to match an IP allow entry (CIDR or named range), a host name match is not enough.
|
||||||
func WithLocalNeedsIPAllow() Option {
|
func WithLocalNeedsIPAllow() Option {
|
||||||
return func(p *Policy) {
|
return func(p *Policy) {
|
||||||
p.localNeedsIPAllow = true
|
p.localNeedsIPAllow = true
|
||||||
@@ -140,9 +140,9 @@ func (p *Policy) allowCheck(host string, ip netip.AddrPort, class addrClass) err
|
|||||||
return p.notAllowedError(denyTarget(host, ip))
|
return p.notAllowedError(denyTarget(host, ip))
|
||||||
}
|
}
|
||||||
if !hostnameOk {
|
if !hostnameOk {
|
||||||
return fmt.Errorf("%s needs an explicit IP allow entry (private/loopback/CGNAT)", denyTarget(host, ip))
|
return fmt.Errorf("%s needs an explicit IP allow entry (non-public address)", denyTarget(host, ip))
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s needs an explicit allow entry (private/loopback/CGNAT)", denyTarget(host, ip))
|
return fmt.Errorf("%s needs an explicit allow entry (non-public address)", denyTarget(host, ip))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Policy) blockReason(host string, ip netip.AddrPort) error {
|
func (p *Policy) blockReason(host string, ip netip.AddrPort) error {
|
||||||
|
|||||||
@@ -33,8 +33,16 @@ func TestCheckAddr(t *testing.T) {
|
|||||||
{name: "allow host", allow: "example.com", host: "example.com", ip: "8.8.8.8", want: true},
|
{name: "allow host", allow: "example.com", host: "example.com", ip: "8.8.8.8", want: true},
|
||||||
{name: "allow cidr", allow: "10.0.0.0/8", ip: "10.0.0.5", want: true},
|
{name: "allow cidr", allow: "10.0.0.0/8", ip: "10.0.0.5", want: true},
|
||||||
{name: "block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5"},
|
{name: "block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5"},
|
||||||
{name: "reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254"},
|
{name: "non cloud link-local is default denied", ip: "::ffff:169.254.1.2"},
|
||||||
{name: "reserved denied ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254"},
|
{name: "link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", want: true},
|
||||||
|
{name: "link-local allowed ipv4-mapped", allow: "169.254.0.0/16", ip: "::ffff:169.254.169.254", want: true},
|
||||||
|
{name: "restricted range allowed by cidr", allow: "192.0.2.0/24", ip: "192.0.2.1", want: true},
|
||||||
|
{name: "ula metadata allowed by private", allow: "private", ip: "fd00:ec2::254", want: true},
|
||||||
|
{name: "reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16"},
|
||||||
|
{name: "reserved denied ipv4-mapped", allow: "168.63.129.16/32", ip: "::ffff:168.63.129.16"},
|
||||||
|
{name: "nat64 reserved denied despite allow", allow: "64:ff9b::/96", ip: "64:ff9b::a9fe:a9fe"},
|
||||||
|
{name: "teredo reserved denied despite allow", allow: "2001::/23", ip: "2001::1"},
|
||||||
|
{name: "protocol assignment allowed by cidr", allow: "2001::/23", ip: "2001:3::1", want: true},
|
||||||
{name: "local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true},
|
{name: "local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true},
|
||||||
{name: "local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, want: true},
|
{name: "local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, want: true},
|
||||||
{name: "local gate accepts cidr", allow: "10.0.0.0/24", ip: "10.0.0.5", localNeedsIPAllow: true, want: true},
|
{name: "local gate accepts cidr", allow: "10.0.0.0/24", ip: "10.0.0.5", localNeedsIPAllow: true, want: true},
|
||||||
@@ -46,7 +54,8 @@ func TestCheckAddr(t *testing.T) {
|
|||||||
{name: "strict rejects unmatched host", allow: "example.com", host: "other.com", ip: "8.8.8.8", strict: true},
|
{name: "strict rejects unmatched host", allow: "example.com", host: "other.com", ip: "8.8.8.8", strict: true},
|
||||||
{name: "strict allows matched host", allow: "example.com", host: "example.com", ip: "8.8.8.8", strict: true, want: true},
|
{name: "strict allows matched host", allow: "example.com", host: "example.com", ip: "8.8.8.8", strict: true, want: true},
|
||||||
{name: "strict block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5", strict: true},
|
{name: "strict block overrides allow", allow: "10.0.0.0/8", block: "10.0.0.5/32", ip: "10.0.0.5", strict: true},
|
||||||
{name: "strict reserved denied by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", strict: true},
|
{name: "strict reserved denied despite allow", allow: "168.63.129.16/32", ip: "168.63.129.16", strict: true},
|
||||||
|
{name: "strict link-local allowed by cidr", allow: "169.254.0.0/16", ip: "169.254.169.254", strict: true, want: true},
|
||||||
{name: "strict local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true, strict: true},
|
{name: "strict local gate ignores host", allow: "example.com", host: "example.com", ip: "10.0.0.5", localNeedsIPAllow: true, strict: true},
|
||||||
{name: "strict local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, strict: true, want: true},
|
{name: "strict local gate accepts builtin", allow: "private", ip: "100.64.0.1", localNeedsIPAllow: true, strict: true, want: true},
|
||||||
} {
|
} {
|
||||||
@@ -63,7 +72,7 @@ func TestCheckAddr(t *testing.T) {
|
|||||||
mode = Strict
|
mode = Strict
|
||||||
}
|
}
|
||||||
err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80))
|
err := NewPolicy("test", mode, opts...).checkAddr(tc.host, netip.AddrPortFrom(addr, 80))
|
||||||
assert.Equal(t, tc.want, err == nil, "%s: %v", tc.name, err)
|
assert.Equal(t, tc.want, err == nil, "%s (%s): %v", tc.name, tc.ip, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +124,9 @@ func TestCheckHostIPs(t *testing.T) {
|
|||||||
|
|
||||||
builtins := NewPolicy("test", Lax, WithAllow("private, loopback", ""))
|
builtins := NewPolicy("test", Lax, WithAllow("private, loopback", ""))
|
||||||
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "100.64.0.1", "::1")))
|
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("8.8.8.8", "100.64.0.1", "::1")))
|
||||||
|
assert.NoError(t, builtins.checkHostIPs(hostURL(t, "http://example.com"), ips("100.100.100.200"))) // cloud metadata is opt-in with its containing range
|
||||||
for _, ip := range []string{
|
for _, ip := range []string{
|
||||||
"0.1.2.3", "100.100.100.200", "168.63.129.16", "169.254.169.254", "192.0.2.1", "192.88.99.1", "198.18.0.1",
|
"0.1.2.3", "168.63.129.16", "169.254.169.254", "192.0.2.1", "192.88.99.1", "198.18.0.1",
|
||||||
"198.51.100.1", "203.0.113.1", "::7f00:1", "::ffff:0:a00:5", "64:ff9b::a9fe:a9fe", "64:ff9b::808:808", "2001::1", "2001:db8::1",
|
"198.51.100.1", "203.0.113.1", "::7f00:1", "::ffff:0:a00:5", "64:ff9b::a9fe:a9fe", "64:ff9b::808:808", "2001::1", "2001:db8::1",
|
||||||
"2002::1", "fe80::1",
|
"2002::1", "fe80::1",
|
||||||
} {
|
} {
|
||||||
|
|||||||
Loaded 100 of 437 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user