mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-15 13:22:05 -06:00
Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b5dfdeeea | ||
|
|
6b5f406f81 | ||
|
|
bf2eb11ec1 | ||
|
|
b0088f060b | ||
|
|
df961a3962 | ||
|
|
58df887de6 | ||
|
|
fff18237d5 | ||
|
|
06a2286930 | ||
|
|
0c6645958b | ||
|
|
660c4b1de6 | ||
|
|
7210b4cc52 | ||
|
|
87c509a405 | ||
|
|
95b69a4a2d | ||
|
|
b60da6d876 | ||
|
|
78698abee0 | ||
|
|
b24cd7839b | ||
|
|
5c69d5710a | ||
|
|
7b466a8a66 | ||
|
|
f9d31d2f11 | ||
|
|
24bceb9f6e | ||
|
|
df01835a83 | ||
|
|
8e5585c5b9 | ||
|
|
7d73ce020e | ||
|
|
f357a03049 | ||
|
|
53cf53cbbc | ||
|
|
ba591d2d5a | ||
|
|
317db14701 | ||
|
|
2f40e966f7 | ||
|
|
88e1e4ccd7 | ||
|
|
e847d6a9c6 | ||
|
|
d0e8294f46 | ||
|
|
b9a99f0f3e | ||
|
|
4f536f1513 | ||
|
|
db5df0494a | ||
|
|
5aea433c71 | ||
|
|
a67b72e57b | ||
|
|
a4cad57174 | ||
|
|
ad8fa1770a | ||
|
|
8fb1547a4c | ||
|
|
fb79898c28 | ||
|
|
96b2997c06 | ||
|
|
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 |
7
.github/workflows/giteabot-backport.yml
vendored
7
.github/workflows/giteabot-backport.yml
vendored
@@ -4,13 +4,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/v*
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -19,8 +20,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
||||
- uses: go-gitea/giteabot@15b18fdedd98a80d0968d0bd9db3a9428814b808 # v1.5.1
|
||||
with:
|
||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||
gitea_fork: giteabot/gitea
|
||||
checks: backport
|
||||
checks: backport,backport_conflict_free
|
||||
|
||||
18
.github/workflows/giteabot.yml
vendored
18
.github/workflows/giteabot.yml
vendored
@@ -1,16 +1,11 @@
|
||||
name: giteabot
|
||||
|
||||
on:
|
||||
# When main advances, rerun merge queue maintenance so the oldest
|
||||
# reviewed/wait-merge PR can be updated against the new base promptly.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# pull_request_target gives this workflow access to GITEABOT_TOKEN on PRs from
|
||||
# forks, which the bot needs to write labels, statuses and comments. Safe here
|
||||
# because the job only runs a pinned action and never checks out PR HEAD.
|
||||
# These PR lifecycle events drive label maintenance, queue maintenance, and
|
||||
# explicit bot actions triggered by relevant label changes.
|
||||
# These PR lifecycle events drive label maintenance, lgtm, and explicit bot
|
||||
# actions triggered by relevant label changes.
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types:
|
||||
- opened
|
||||
@@ -26,8 +21,7 @@ on:
|
||||
- giteabot-review
|
||||
types:
|
||||
- requested
|
||||
# Periodic maintenance is still useful as a backstop for queue cleanup and
|
||||
# other housekeeping, even though main pushes now trigger it promptly.
|
||||
# Daily housekeeping like locks, stale feedback issues, last calls and milestones
|
||||
schedule:
|
||||
- cron: "15 3 * * *"
|
||||
# Allow maintainers to rerun selected checks manually when debugging bot
|
||||
@@ -37,7 +31,7 @@ on:
|
||||
checks:
|
||||
description: Comma-separated list of non-backport checks to run
|
||||
required: false
|
||||
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
|
||||
default: backport_branches,backport_conflict_free,labels,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -56,7 +50,7 @@ jobs:
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
steps:
|
||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
||||
- uses: go-gitea/giteabot@15b18fdedd98a80d0968d0bd9db3a9428814b808 # v1.5.1
|
||||
with:
|
||||
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,backport_conflict_free,labels,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
||||
|
||||
16
.github/workflows/merge-group.yml
vendored
Normal file
16
.github/workflows/merge-group.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: merge-group
|
||||
|
||||
# Reports the required giteabot/lgtm check on merge queue commits. giteabot only sets it
|
||||
# on PR heads, which already passed it before entering the queue.
|
||||
|
||||
on:
|
||||
merge_group:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
lgtm:
|
||||
name: giteabot/lgtm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: "true"
|
||||
1
.github/workflows/pull-compliance.yml
vendored
1
.github/workflows/pull-compliance.yml
vendored
@@ -2,6 +2,7 @@ name: compliance
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/pull-db-tests.yml
vendored
1
.github/workflows/pull-db-tests.yml
vendored
@@ -2,6 +2,7 @@ name: db-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
|
||||
1
.github/workflows/pull-e2e-tests.yml
vendored
1
.github/workflows/pull-e2e-tests.yml
vendored
@@ -2,6 +2,7 @@ name: e2e-tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
|
||||
4
.github/workflows/release-create-tag.yml
vendored
4
.github/workflows/release-create-tag.yml
vendored
@@ -13,7 +13,9 @@ permissions: {}
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release-signing
|
||||
environment:
|
||||
name: release-signing
|
||||
deployment: false
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
|
||||
@@ -37,8 +37,15 @@ jobs:
|
||||
# retry snapcraft uploads which can be flaky
|
||||
- name: Publish snap
|
||||
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
|
||||
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"
|
||||
sleep 15
|
||||
done
|
||||
|
||||
4
.github/workflows/release-tag-version.yml
vendored
4
.github/workflows/release-tag-version.yml
vendored
@@ -76,6 +76,10 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -48,7 +48,7 @@ FROM docker.io/library/alpine:3.24 AS gitea
|
||||
|
||||
EXPOSE 22 3000
|
||||
|
||||
RUN apk --no-cache add \
|
||||
RUN apk --no-cache upgrade && apk --no-cache add \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
|
||||
@@ -43,7 +43,7 @@ FROM docker.io/library/alpine:3.24 AS gitea-rootless
|
||||
|
||||
EXPOSE 2222 3000
|
||||
|
||||
RUN apk --no-cache add \
|
||||
RUN apk --no-cache upgrade && apk --no-cache add \
|
||||
bash \
|
||||
ca-certificates \
|
||||
dumb-init \
|
||||
|
||||
2
Makefile
2
Makefile
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
5
assets/go-licenses.json
generated
5
assets/go-licenses.json
generated
@@ -119,11 +119,6 @@
|
||||
"path": "github.com/andybalholm/brotli/LICENSE",
|
||||
"licenseText": "Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
|
||||
},
|
||||
{
|
||||
"name": "github.com/andybalholm/brotli/flate",
|
||||
"path": "github.com/andybalholm/brotli/flate/LICENSE",
|
||||
"licenseText": "Copyright (c) 2009 The Go Authors. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Google Inc. nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
|
||||
},
|
||||
{
|
||||
"name": "github.com/andybalholm/cascadia",
|
||||
"path": "github.com/andybalholm/cascadia/LICENSE",
|
||||
|
||||
@@ -33,7 +33,7 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
|
||||
return err
|
||||
}
|
||||
|
||||
users, err := user_model.GetAllUsers(ctx)
|
||||
users, err := user_model.GetAllUsersForAdminListing(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -41,17 +41,17 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
|
||||
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -638,7 +638,8 @@ ENABLED = true
|
||||
;; * https://github.com/git-ecosystem/git-credential-manager
|
||||
;; * https://gitea.com/gitea/tea
|
||||
;; * 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.
|
||||
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
|
||||
@@ -810,7 +811,7 @@ LEVEL = Info
|
||||
;[git.config]
|
||||
;diff.algorithm = histogram
|
||||
;core.logAllRefUpdates = true
|
||||
;gc.reflogExpire = 90
|
||||
;maintenance.strategy = gc
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -1098,8 +1099,9 @@ LEVEL = Info
|
||||
;; Force ssh:// clone url instead of scp-style uri when default SSH port is used
|
||||
;USE_COMPAT_SSH_URI = false
|
||||
;;
|
||||
;; Value for the "go get" request returns the repository url as https or ssh, default is https
|
||||
;GO_GET_CLONE_URL_PROTOCOL = https
|
||||
;; Scheme of the returned URL for the "go get" response.
|
||||
;; 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
|
||||
;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false
|
||||
@@ -3082,7 +3084,7 @@ LEVEL = Info
|
||||
;LOG_RETENTION_DAYS = 365
|
||||
;; 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.
|
||||
;; 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
|
||||
;; 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
|
||||
|
||||
@@ -74,15 +74,13 @@ Changes to Gitea must be reviewed before they are accepted, including changes fr
|
||||
|
||||
We require two maintainer approvals for every PR. When that is satisfied, your PR gets the `lgtm/done` label. After that, you mainly fix merge conflicts and respond to or implement maintainer requests; maintainers drive getting the PR merged.
|
||||
|
||||
If a PR has `lgtm/done`, no open discussions, and no merge conflicts, any maintainer may add `reviewed/wait-merge`. That puts the PR in the merge queue. PRs are merged from the queue in the order of this list:
|
||||
If a PR has `lgtm/done`, no open discussions, and no merge conflicts, a merger rewrites its title and description into the final [commit message](#commit-messages) and clicks **Merge when ready**. That adds the PR to the [merge queue](https://github.com/go-gitea/gitea/queue/main), which tests it on top of the latest `main` and the PRs ahead of it, and merges it once CI passes. Edits to the title and description no longer reach the commit once the queue starts building the PR, so make them before queueing.
|
||||
|
||||
<https://github.com/go-gitea/gitea/pulls?q=is%3Apr+label%3Areviewed%2Fwait-merge+sort%3Acreated-asc+is%3Aopen>
|
||||
|
||||
Gitea uses its own tool, <https://github.com/go-gitea/gitea/blob/main/.github/workflows/giteabot.yml>, to automate parts of the review process. The backporter:
|
||||
Gitea uses its own tool, <https://github.com/go-gitea/gitea/blob/main/.github/workflows/giteabot.yml>, to automate parts of the review process. It:
|
||||
|
||||
- Counts maintainer approvals and sets the `lgtm/*` labels and the `giteabot/lgtm` status.
|
||||
- Takes a PR out of the merge queue when it loses its approval.
|
||||
- Creates a backport PR when needed after the initial PR merges.
|
||||
- Removes the PR from the merge queue after it merges.
|
||||
- Keeps the oldest branch in the merge queue up to date with merges.
|
||||
|
||||
### Final call
|
||||
|
||||
@@ -164,7 +162,7 @@ Any account with write access (including bots and TOC members) **must** use [2FA
|
||||
|
||||
Mergers are the maintainers who carry out the final merge of approved PRs. Their responsibilities, described throughout this guide, are:
|
||||
|
||||
- Merging PRs from the [merge queue](#getting-prs-merged) in order, once a PR has `lgtm/done`, no open discussions, and no merge conflicts.
|
||||
- Adding PRs to the [merge queue](#getting-prs-merged) once a PR has `lgtm/done`, no open discussions, and no merge conflicts.
|
||||
- Rewriting the PR title and description prior to the merge, making the [commit message](#commit-messages) clear.\
|
||||
In particular, mergers should edit the PR description.\
|
||||
Mergers should **not** edit the actual commit message except to remove unnecessary information. Because of that, even if users are looking at the PR, they can understand what changed.
|
||||
|
||||
@@ -24,6 +24,8 @@ Now, that's not a requirement anymore as our [backport bot](https://github.com/G
|
||||
|
||||
The `backport/manual` label signifies either that you want to backport the change yourself, or that there were conflicts when backporting, thus you **must** do it yourself.
|
||||
|
||||
The bot labels open PRs targeting `main` that have a `backport/<version>` label with `backport/conflict-free` when they would cherry-pick onto the latest release branch without conflicts.
|
||||
|
||||
### Format of backport PRs
|
||||
|
||||
The title of backport PRs should be
|
||||
|
||||
22
go.mod
22
go.mod
@@ -2,7 +2,7 @@ module gitea.dev
|
||||
|
||||
go 1.27
|
||||
|
||||
toolchain go1.27.1
|
||||
toolchain go1.27.4
|
||||
|
||||
require (
|
||||
connectrpc.com/connect v1.21.0
|
||||
@@ -11,9 +11,9 @@ require (
|
||||
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
|
||||
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
|
||||
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.5.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/sshsig v0.0.0-20260317195500-b9f38cf0d432
|
||||
github.com/Azure/go-ntlmssp v0.1.1
|
||||
@@ -94,15 +94,15 @@ require (
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
golang.org/x/crypto v0.57.0
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
|
||||
golang.org/x/crypto v0.58.0
|
||||
golang.org/x/image v0.46.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/sync v0.23.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/text v0.42.0
|
||||
golang.org/x/sync v0.24.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/text v0.43.0
|
||||
google.golang.org/grpc v1.84.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
gopkg.in/ini.v1 v1.67.3
|
||||
@@ -120,7 +120,7 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.18.2 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.7 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
@@ -252,7 +252,7 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org v0.0.0-20260112195520-a5071408f32f // 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
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
|
||||
44
go.sum
44
go.sum
@@ -18,14 +18,14 @@ gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6 h1:YWzVGeC/8SZThrJS4
|
||||
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6/go.mod h1:KDvcfMUoXfATPHs2mbMoXFTXT45/FAFAS39waz9tPk0=
|
||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw=
|
||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96/go.mod h1:VyMQP6ue6MKHM8UsOXfNfuMKD0oSAWZdXVcpHIN2yaY=
|
||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4 h1:IFT+hup2xejHqdhS7keYWioqfmxdnfblFDTGoOwcZ+o=
|
||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU=
|
||||
gitea.com/lunny/levelqueue v0.5.1 h1:15q1oBWaO2Aw9t3rDthQSTX+vBNgjYNkCgRJgES9gA8=
|
||||
gitea.com/lunny/levelqueue v0.5.1/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU=
|
||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
|
||||
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/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
|
||||
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
|
||||
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
|
||||
gitea.dev/sdk v1.3.0 h1:4U95+Ou6WHCQiRPDEyAzvl1Q7cxg5RUTWkqmOXr5w4I=
|
||||
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/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 h1:3Fcz1QzlS7Jv4FT2KI3cHNSZL+KPN3dXxurn9f3YL/Y=
|
||||
@@ -71,8 +71,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
|
||||
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
@@ -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/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/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
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 h1:FtzHH3s777Nx50YcPAcodAPm1mNakc228nMYbr3f+90=
|
||||
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/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||
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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
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.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
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/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
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.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||
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/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||
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.7.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.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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.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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
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/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=
|
||||
@@ -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.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.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
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.3/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.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.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
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.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.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -93,7 +93,12 @@ const artifactKeepForever timeutil.TimeStamp = 0
|
||||
|
||||
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
|
||||
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 {
|
||||
return artifactKeepForever
|
||||
|
||||
@@ -5,6 +5,12 @@ package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -25,3 +31,21 @@ func TestKeepLatestAttemptArtifacts(t *testing.T) {
|
||||
}
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ type ActionRun struct {
|
||||
RepoID int64 `xorm:"unique(repo_index) index(repo_status)"`
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
OwnerID int64 `xorm:"index"`
|
||||
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
|
||||
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||
Index int64 `xorm:"unique(repo_index)"` // a unique number for each run of a repository
|
||||
TriggerUserID int64 `xorm:"index"`
|
||||
TriggerUser *user_model.User `xorm:"-"`
|
||||
ScheduleID int64
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
|
||||
// MaxJobNumPerRun is the maximum number of jobs in a single run.
|
||||
// https://docs.github.com/en/actions/reference/limits#existing-system-limits
|
||||
// TODO: check this limit when creating jobs
|
||||
const MaxJobNumPerRun = 256
|
||||
|
||||
// ActionRunJob represents a job of a run
|
||||
@@ -100,7 +99,7 @@ type ActionRunJob struct {
|
||||
|
||||
// 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.
|
||||
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.
|
||||
// For a reusable workflow caller, true means children rows exist and CallPayload is populated.
|
||||
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||
@@ -440,14 +439,6 @@ func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*
|
||||
return out
|
||||
}
|
||||
|
||||
// hasWaitingJobsToPick reports whether any waiting, unclaimed, non-reusable job
|
||||
// remains in the repo, i.e. work that an idle runner could still pick up.
|
||||
func hasWaitingJobsToPick(ctx context.Context, repoID int64) (bool, error) {
|
||||
return db.GetEngine(ctx).
|
||||
Where("repo_id = ? AND task_id = ? AND status = ? AND is_reusable_caller = ?", repoID, 0, StatusWaiting, false).
|
||||
Exist(&ActionRunJob{})
|
||||
}
|
||||
|
||||
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
||||
e := db.GetEngine(ctx)
|
||||
|
||||
@@ -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
|
||||
// every job completion would needlessly bump the global version and wake
|
||||
// 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 {
|
||||
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,
|
||||
// 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.
|
||||
descendants := CollectAllDescendantJobs(caller, attemptJobs)
|
||||
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 {
|
||||
cancelled, err := cancelOneJob(ctx, c, force)
|
||||
for _, job := range append(descendants, caller) {
|
||||
if !callersWithChildren.Contains(job.ID) {
|
||||
cancelled, err := cancelOneJob(ctx, job, force)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
if cancelled != nil {
|
||||
cancelledJobs = append(cancelledJobs, cancelled)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// the job is a caller and its children's cascade already re-aggregated it
|
||||
reloaded, err := GetRunJobByRunAndID(ctx, job.RunID, job.ID)
|
||||
if err != nil {
|
||||
return cancelledJobs, err
|
||||
}
|
||||
if cancelled != nil {
|
||||
cancelledJobs = append(cancelledJobs, cancelled)
|
||||
if reloaded.Status != job.Status {
|
||||
cancelledJobs = append(cancelledJobs, reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
if c, err := cancelOneJob(ctx, caller, force); err != nil {
|
||||
return cancelledJobs, err
|
||||
} else if c != nil {
|
||||
cancelledJobs = append(cancelledJobs, c)
|
||||
}
|
||||
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})
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
var (
|
||||
// keep in sync with CreateTaskForRunner
|
||||
queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0}
|
||||
// a cancelling job still occupies its runner
|
||||
runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -397,3 +398,72 @@ func TestForceCancelJobs(t *testing.T) {
|
||||
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"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
webhook_module "gitea.dev/modules/webhook"
|
||||
@@ -110,7 +111,7 @@ func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) ([
|
||||
jobs, err := CancelPreviousJobs(
|
||||
ctx,
|
||||
repo.ID,
|
||||
repo.DefaultBranch,
|
||||
git.RefNameFromBranch(repo.DefaultBranch).String(),
|
||||
"",
|
||||
webhook_module.HookEventSchedule,
|
||||
)
|
||||
|
||||
@@ -65,26 +65,22 @@ func (opts FindScopedWorkflowSourceOpts) ToConds() builder.Cond {
|
||||
if opts.SourceRepoID != 0 {
|
||||
cond = cond.And(builder.Eq{"source_repo_id": opts.SourceRepoID})
|
||||
}
|
||||
return cond
|
||||
return cond.And(scopedWorkflowSourceOwnedCond())
|
||||
}
|
||||
|
||||
func scopedWorkflowSourceOwnedCond() builder.Cond {
|
||||
return builder.Eq{"owner_id": 0}.Or(builder.Expr("EXISTS (SELECT 1 FROM repository WHERE repository.id = action_scoped_workflow_source.source_repo_id AND repository.owner_id = action_scoped_workflow_source.owner_id)"))
|
||||
}
|
||||
|
||||
// GetEffectiveScopedWorkflowSources returns the scoped-workflow sources effective for a repo owned by repoOwnerID:
|
||||
// the owner's own sources plus instance-level (owner_id=0) sources.
|
||||
func GetEffectiveScopedWorkflowSources(ctx context.Context, repoOwnerID int64) ([]*ActionScopedWorkflowSource, error) {
|
||||
owners := []int64{0}
|
||||
if repoOwnerID != 0 {
|
||||
owners = append(owners, repoOwnerID)
|
||||
}
|
||||
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners})
|
||||
return db.Find[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{0, repoOwnerID}})
|
||||
}
|
||||
|
||||
// IsScopedWorkflowSourceEffective reports whether sourceRepoID is a scoped-workflow source effective for a repo owned by repoOwnerID.
|
||||
func IsScopedWorkflowSourceEffective(ctx context.Context, repoOwnerID, sourceRepoID int64) (bool, error) {
|
||||
owners := []int64{0}
|
||||
if repoOwnerID != 0 {
|
||||
owners = append(owners, repoOwnerID)
|
||||
}
|
||||
return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: owners, SourceRepoID: sourceRepoID}.ToConds())
|
||||
return db.Exist[ActionScopedWorkflowSource](ctx, FindScopedWorkflowSourceOpts{OwnerIDs: []int64{0, repoOwnerID}, SourceRepoID: sourceRepoID}.ToConds())
|
||||
}
|
||||
|
||||
// IsWorkflowRequiredInSources reports whether workflowID from sourceRepoID is required by any of the given sources.
|
||||
@@ -138,12 +134,10 @@ func GetScopedWorkflowSourcesByOwner(ctx context.Context, ownerID int64) ([]*Act
|
||||
|
||||
// GetScopedWorkflowSource returns the (owner, repo) source registration or a NotExist error.
|
||||
func GetScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) (*ActionScopedWorkflowSource, error) {
|
||||
src := &ActionScopedWorkflowSource{}
|
||||
has, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Get(src)
|
||||
src, has, err := db.Get[ActionScopedWorkflowSource](ctx, builder.Eq{"owner_id": ownerID, "source_repo_id": repoID}.And(scopedWorkflowSourceOwnedCond()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
} else if !has {
|
||||
return nil, util.NewNotExistErrorf("scoped workflow source (owner %d, repo %d) does not exist", ownerID, repoID)
|
||||
}
|
||||
return src, nil
|
||||
@@ -181,3 +175,9 @@ func RemoveScopedWorkflowSource(ctx context.Context, ownerID, repoID int64) erro
|
||||
_, err := db.GetEngine(ctx).Where("owner_id = ? AND source_repo_id = ?", ownerID, repoID).Delete(new(ActionScopedWorkflowSource))
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveOwnerScopedWorkflowSources removes every owner-level registration of repoID, as only its current owner may hold one.
|
||||
func RemoveOwnerScopedWorkflowSources(ctx context.Context, repoID int64) error {
|
||||
_, err := db.GetEngine(ctx).Where("owner_id <> 0 AND source_repo_id = ?", repoID).Delete(new(ActionScopedWorkflowSource))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -48,26 +48,28 @@ func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
rows := []*ActionScopedWorkflowSource{
|
||||
{OwnerID: 2, SourceRepoID: 100, WorkflowConfigs: nil}, // org 2 registers repo 100 (optional)
|
||||
{OwnerID: 0, SourceRepoID: 100, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true, Patterns: []string{"p"}}}}, // instance also registers repo 100 (required)
|
||||
{OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true, Patterns: []string{"p"}}}}, // instance source 200
|
||||
{OwnerID: 3, SourceRepoID: 300, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true, Patterns: []string{"p"}}}}, // a different owner's source
|
||||
}
|
||||
for _, r := range rows {
|
||||
require.NoError(t, db.Insert(ctx, r))
|
||||
for _, source := range []*ActionScopedWorkflowSource{
|
||||
{OwnerID: 2, SourceRepoID: 1},
|
||||
{OwnerID: 0, SourceRepoID: 1, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"a.yml": {Required: true}}},
|
||||
{OwnerID: 0, SourceRepoID: 200, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"b.yml": {Required: true}}},
|
||||
{OwnerID: 3, SourceRepoID: 3, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true}}},
|
||||
{OwnerID: 2, SourceRepoID: 3, WorkflowConfigs: map[string]*ScopedWorkflowConfig{"c.yml": {Required: true}}},
|
||||
} {
|
||||
require.NoError(t, db.Insert(ctx, source))
|
||||
}
|
||||
|
||||
// owner 2 sees its own sources plus instance-level ones, but not owner 3's.
|
||||
owner2, err := GetEffectiveScopedWorkflowSources(ctx, 2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, owner2, 3)
|
||||
_, err = GetScopedWorkflowSource(ctx, 2, 3)
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
|
||||
required, err := IsScopedWorkflowRequired(ctx, 2, 100, "a.yml")
|
||||
required, err := IsScopedWorkflowRequired(ctx, 2, 1, "a.yml")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, required, "instance marks a.yml required → required for owner 2 even though org left it optional")
|
||||
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 100, "x.yml")
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 1, "x.yml")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, required)
|
||||
|
||||
@@ -76,12 +78,12 @@ func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
|
||||
assert.True(t, required)
|
||||
|
||||
// owner 3's source must not be effective for owner 2.
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 300, "c.yml")
|
||||
required, err = IsScopedWorkflowRequired(ctx, 2, 3, "c.yml")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, required)
|
||||
|
||||
// IsScopedWorkflowSourceEffective: owner-level and instance-level sources are effective; another owner's is not.
|
||||
effective, err := IsScopedWorkflowSourceEffective(ctx, 2, 100)
|
||||
effective, err := IsScopedWorkflowSourceEffective(ctx, 2, 1)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "owner 2's own source")
|
||||
|
||||
@@ -89,7 +91,7 @@ func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "instance-level source is effective for any owner")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 300)
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 2, 3)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, effective, "owner 3's source is not effective for owner 2")
|
||||
|
||||
@@ -97,7 +99,7 @@ func TestGetEffectiveScopedWorkflowSources(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.False(t, effective, "unknown source repo")
|
||||
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 3, 300)
|
||||
effective, err = IsScopedWorkflowSourceEffective(ctx, 3, 3)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, effective, "owner 3's own source is effective for owner 3")
|
||||
}
|
||||
@@ -107,8 +109,8 @@ func TestScopedWorkflowSourceCRUD(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
// add is idempotent
|
||||
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10))
|
||||
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 10))
|
||||
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 4))
|
||||
require.NoError(t, AddScopedWorkflowSource(ctx, 5, 4))
|
||||
sources, err := GetScopedWorkflowSourcesByOwner(ctx, 5)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, sources, 1)
|
||||
@@ -118,20 +120,20 @@ func TestScopedWorkflowSourceCRUD(t *testing.T) {
|
||||
"a.yml": {Required: true, Patterns: []string{"src: a.yml / *"}},
|
||||
"b.yml": {Required: false, Patterns: []string{"src: b.yml / build (push)"}},
|
||||
}
|
||||
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, configs))
|
||||
src, err := GetScopedWorkflowSource(ctx, 5, 10)
|
||||
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 4, configs))
|
||||
src, err := GetScopedWorkflowSource(ctx, 5, 4)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, configs, src.WorkflowConfigs)
|
||||
|
||||
// clearing the configs works
|
||||
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 10, nil))
|
||||
src, err = GetScopedWorkflowSource(ctx, 5, 10)
|
||||
require.NoError(t, SetScopedWorkflowSourceConfigs(ctx, 5, 4, nil))
|
||||
src, err = GetScopedWorkflowSource(ctx, 5, 4)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, src.WorkflowConfigs)
|
||||
|
||||
// remove
|
||||
require.NoError(t, RemoveScopedWorkflowSource(ctx, 5, 10))
|
||||
_, err = GetScopedWorkflowSource(ctx, 5, 10)
|
||||
require.NoError(t, RemoveScopedWorkflowSource(ctx, 5, 4))
|
||||
_, err = GetScopedWorkflowSource(ctx, 5, 4)
|
||||
assert.ErrorIs(t, err, util.ErrNotExist)
|
||||
sources, err = GetScopedWorkflowSourcesByOwner(ctx, 5)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -236,13 +236,13 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro
|
||||
|
||||
func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
||||
if step.Name != "" {
|
||||
name = string(step.Name) // the step has an explicit name
|
||||
name = step.Name // the step has an explicit name
|
||||
} else {
|
||||
// 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
|
||||
// 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
|
||||
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())
|
||||
}
|
||||
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
|
||||
// concurrent claim by another runner (which would lose the optimistic lock on
|
||||
// job #1) does not leave the remaining jobs permanently unassigned.
|
||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
|
||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (task *ActionTask, cancelledJobs []*ActionRunJob, err error) {
|
||||
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
|
||||
log.Trace("runner labels: %v", runner.AgentLabels)
|
||||
@@ -286,17 +275,17 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
var cursorUpdated timeutil.TimeStamp
|
||||
var cursorID int64
|
||||
for {
|
||||
cond := baseCond
|
||||
sess := JobQueueOptions{RepoID: runner.RepoID, OwnerID: runner.OwnerID, Status: StatusWaiting}.session(ctx)
|
||||
if cursorID > 0 {
|
||||
cond = cond.And(builder.Or(
|
||||
sess.And(builder.Or(
|
||||
builder.Gt{"updated": cursorUpdated},
|
||||
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
||||
))
|
||||
}
|
||||
|
||||
var jobs []*ActionRunJob
|
||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||
return nil, false, err
|
||||
if err := sess.Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||
return nil, cancelledJobs, err
|
||||
}
|
||||
// A short page means no waiting jobs remain beyond it.
|
||||
isLastPage := len(jobs) < pickTaskBatchSize
|
||||
@@ -309,28 +298,29 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
||||
if !runner.CanMatchLabels(v.RunsOn) {
|
||||
continue
|
||||
}
|
||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
task, cancelled, err := claimJobForRunner(ctx, runner, v)
|
||||
if cancelled {
|
||||
cancelledJobs = append(cancelledJobs, v)
|
||||
}
|
||||
if ok {
|
||||
return task, true, nil
|
||||
if err != nil || task != 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.
|
||||
}
|
||||
|
||||
if isLastPage {
|
||||
return nil, false, nil
|
||||
return nil, cancelledJobs, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// claimJobForRunner attempts to atomically claim job for runner inside its own
|
||||
// transaction. Returns (task, true, nil) on success, or (nil, false, nil) when
|
||||
// another runner wins the optimistic-lock race (the caller should try the next
|
||||
// candidate job).
|
||||
// transaction. Returns the task on success, cancelled when the job's repository
|
||||
// has Actions disabled, or neither when another runner wins the optimistic-lock
|
||||
// race (the caller should try the next candidate job).
|
||||
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
|
||||
var resultTask *ActionTask
|
||||
var cancelled bool
|
||||
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
e := db.GetEngine(ctx)
|
||||
@@ -338,6 +328,15 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
|
||||
if err := job.LoadAttributes(ctx); err != nil {
|
||||
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()
|
||||
job.Started = now
|
||||
@@ -403,13 +402,10 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
|
||||
return nil
|
||||
})
|
||||
|
||||
if errors.Is(err, errJobAlreadyClaimed) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
if err != nil && !errors.Is(err, errJobAlreadyClaimed) {
|
||||
return nil, false, err
|
||||
}
|
||||
return resultTask, true, nil
|
||||
return resultTask, cancelled, nil
|
||||
}
|
||||
|
||||
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -94,14 +95,14 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
{
|
||||
name: "very long name truncated",
|
||||
jobStep: &jobparser.Step{
|
||||
Name: jobparser.BlockSafeString(strings.Repeat("a", 300)),
|
||||
Name: strings.Repeat("a", 300),
|
||||
},
|
||||
expected: strings.Repeat("a", 252) + "…",
|
||||
},
|
||||
{
|
||||
name: "very long run truncated",
|
||||
jobStep: &jobparser.Step{
|
||||
Run: jobparser.BlockSafeString(strings.Repeat("a", 300)),
|
||||
Run: strings.Repeat("a", 300),
|
||||
},
|
||||
expected: "Run " + strings.Repeat("a", 248) + "…",
|
||||
},
|
||||
@@ -258,6 +259,11 @@ func TestReleaseTaskForRunner(t *testing.T) {
|
||||
}
|
||||
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{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
@@ -279,11 +285,13 @@ func TestReleaseTaskForRunner(t *testing.T) {
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
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.True(t, ok)
|
||||
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})
|
||||
require.Equal(t, StatusRunning, claimed.Status)
|
||||
@@ -324,6 +332,11 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
}
|
||||
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.
|
||||
// With a page size of 2 the matching job only appears on the third page.
|
||||
for i := range 5 {
|
||||
@@ -362,11 +375,13 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
}
|
||||
runner.GenerateAndFillToken()
|
||||
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.True(t, ok)
|
||||
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})
|
||||
assert.Equal(t, StatusRunning, claimed.Status)
|
||||
|
||||
@@ -180,15 +180,14 @@ func updateIssueNotification(ctx context.Context, userID, issueID, commentID, up
|
||||
}
|
||||
|
||||
// NOTICE: Only update comment id when the before notification on this issue is read, otherwise you may miss some old comments.
|
||||
// But we need update update_by so that the notification will be reorder
|
||||
var cols []string
|
||||
// Always update so that updated_unix moves the notification to the top of the list
|
||||
// Actually, it's unclear what UpdatedBy is used for, or maybe not used at all, or maybe it should just be dropped.
|
||||
notification.UpdatedBy = updatedByID
|
||||
cols := []string{"updated_by"}
|
||||
if notification.Status == NotificationStatusRead {
|
||||
notification.Status = NotificationStatusUnread
|
||||
notification.CommentID = commentID
|
||||
cols = []string{"status", "update_by", "comment_id"}
|
||||
} else {
|
||||
notification.UpdatedBy = updatedByID
|
||||
cols = []string{"update_by"}
|
||||
cols = append(cols, "status", "comment_id")
|
||||
}
|
||||
|
||||
_, err = db.GetEngine(ctx).ID(notification.ID).Cols(cols...).Update(notification)
|
||||
|
||||
@@ -71,16 +71,6 @@ func (opts FindNotificationOptions) ToOrders() string {
|
||||
// receiverID > 0 just send to receiver, else send to all watcher
|
||||
// Returns the set of user IDs whose notification rows were created or updated.
|
||||
func CreateOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) ([]int64, error) {
|
||||
var notifiedIDs []int64
|
||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||
var innerErr error
|
||||
notifiedIDs, innerErr = createOrUpdateIssueNotifications(ctx, issueID, commentID, notificationAuthorID, receiverID)
|
||||
return innerErr
|
||||
})
|
||||
return notifiedIDs, err
|
||||
}
|
||||
|
||||
func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, notificationAuthorID, receiverID int64) ([]int64, error) {
|
||||
// init
|
||||
var toNotify container.Set[int64]
|
||||
notifications, err := db.Find[Notification](ctx, FindNotificationOptions{
|
||||
@@ -181,7 +171,11 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
|
||||
if notificationExists(notifications, issue.ID, userID) {
|
||||
err = updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID)
|
||||
} else {
|
||||
err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID)
|
||||
if err = createIssueNotification(ctx, userID, issue, commentID, notificationAuthorID); err != nil {
|
||||
if errUpdate := updateIssueNotification(ctx, userID, issue.ID, commentID, notificationAuthorID); errUpdate == nil {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -31,6 +31,18 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) {
|
||||
|
||||
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 4, IssueID: issue.ID})
|
||||
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
|
||||
|
||||
// unread notification should get latest updated_unix when re-notified
|
||||
_, err = db.GetEngine(t.Context()).ID(notf.ID).NoAutoTime().Cols("updated_unix").Update(&activities_model.Notification{UpdatedUnix: 123})
|
||||
assert.NoError(t, err)
|
||||
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: notf.ID})
|
||||
assert.EqualValues(t, 123, notf.UpdatedUnix)
|
||||
|
||||
_, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), issue.ID, 0, 3, 0)
|
||||
assert.NoError(t, err)
|
||||
notf = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: notf.ID})
|
||||
assert.NotEqualValues(t, 123, notf.UpdatedUnix)
|
||||
assert.EqualValues(t, 3, notf.UpdatedBy)
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) {
|
||||
|
||||
@@ -85,7 +85,7 @@ func (opts FindGPGKeyOptions) ToConds() builder.Cond {
|
||||
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})
|
||||
}
|
||||
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
|
||||
key := new(PublicKey)
|
||||
has, err := db.GetEngine(ctx).
|
||||
Where("content = ?", principalKey).
|
||||
Where("content = ? AND type = ?", principalKey, KeyTypePrincipal).
|
||||
Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -224,7 +224,7 @@ func (opts FindPublicKeyOptions) ToOrders() string {
|
||||
|
||||
func (opts FindPublicKeyOptions) ToConds() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.OwnerID > 0 {
|
||||
if opts.OwnerID != 0 {
|
||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||
}
|
||||
if opts.Fingerprint != "" {
|
||||
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/42wim/sshsig"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_SSHParsePublicKey(t *testing.T) {
|
||||
@@ -481,6 +483,20 @@ func TestPublicKeysAreExternallyManaged(t *testing.T) {
|
||||
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.
|
||||
func TestCheckPublicKeyStringOversized(t *testing.T) {
|
||||
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
|
||||
|
||||
@@ -44,9 +44,10 @@ func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
|
||||
return at, nil
|
||||
}
|
||||
|
||||
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error {
|
||||
_, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
|
||||
return err
|
||||
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken, oldHash string) (bool, error) {
|
||||
affected, err := db.GetEngine(ctx).ID(t.ID).Where("token_hash = ?", oldHash).
|
||||
Cols("token_hash", "expires_unix").Update(t)
|
||||
return affected == 1, err
|
||||
}
|
||||
|
||||
func DeleteAuthTokenByID(ctx context.Context, id string) error {
|
||||
|
||||
@@ -88,6 +88,16 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
|
||||
DisplayName: "Gitea App",
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/globallock"
|
||||
"gitea.dev/modules/secret"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -48,6 +51,11 @@ func (err ErrTwoFactorNotEnrolled) Unwrap() error {
|
||||
return util.ErrNotExist
|
||||
}
|
||||
|
||||
const (
|
||||
totpMaxAttempts = 5
|
||||
totpAttemptsTTL = 15 * 60 // seconds, renewed by every failed attempt
|
||||
)
|
||||
|
||||
// TwoFactor represents a two-factor authentication token.
|
||||
type TwoFactor struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
@@ -120,26 +128,59 @@ func (t *TwoFactor) validateTOTP(passcode string) (bool, error) {
|
||||
return totp.Validate(passcode, secretStr), nil
|
||||
}
|
||||
|
||||
type TwoFactorResult int
|
||||
|
||||
const (
|
||||
TwoFactorResultValid TwoFactorResult = iota + 1
|
||||
TwoFactorResultInvalid
|
||||
TwoFactorResultTooManyAttempts
|
||||
)
|
||||
|
||||
// ValidateAndConsumeTOTP validates the passcode and atomically records it as used so that the
|
||||
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). It returns false for an
|
||||
// invalid passcode as well as for a replay, including the case where a concurrent request with
|
||||
// the same passcode won the race first. All TOTP login surfaces must go through this helper.
|
||||
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (bool, error) {
|
||||
ok, err := t.validateTOTP(passcode)
|
||||
if err != nil || !ok {
|
||||
return false, err
|
||||
// same passcode cannot be redeemed more than once (RFC 6238 §5.2). Invalid passcodes and replays
|
||||
// return TwoFactorResultInvalid; exhausted attempts return TwoFactorResultTooManyAttempts.
|
||||
// All TOTP login surfaces must go through this helper.
|
||||
func (t *TwoFactor) ValidateAndConsumeTOTP(ctx context.Context, passcode string) (result TwoFactorResult, err error) {
|
||||
if passcode == "" {
|
||||
// password-only Basic auth clients must not use up the attempts
|
||||
return TwoFactorResultInvalid, nil
|
||||
}
|
||||
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
|
||||
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
|
||||
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
|
||||
t.LastUsedPasscode = passcode
|
||||
n, err := db.GetEngine(ctx).ID(t.ID).
|
||||
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
|
||||
Cols("last_used_passcode").Update(t)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
// the lock spans check, validation and counting so that concurrent requests cannot exceed the limit
|
||||
attemptsKey := fmt.Sprintf("twofa_attempts_%d", t.UID)
|
||||
err = globallock.LockAndDo(ctx, attemptsKey, func(ctx context.Context) error {
|
||||
value, _ := cache.GetCache().Get(attemptsKey)
|
||||
attempts, _ := strconv.Atoi(value)
|
||||
if attempts >= totpMaxAttempts {
|
||||
result = TwoFactorResultTooManyAttempts
|
||||
return nil
|
||||
}
|
||||
|
||||
valid, err := t.validateTOTP(passcode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if valid {
|
||||
// Conditional update: only a row whose stored passcode differs from this one is updated, so a
|
||||
// replay (or a concurrent duplicate) matches zero rows and is rejected. The row lock taken by
|
||||
// the UPDATE serializes racing requests, closing the read-validate-write TOCTOU window.
|
||||
t.LastUsedPasscode = passcode
|
||||
n, err := db.GetEngine(ctx).ID(t.ID).
|
||||
Where(builder.Or(builder.IsNull{"last_used_passcode"}, builder.Neq{"last_used_passcode": passcode})).
|
||||
Cols("last_used_passcode").Update(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
valid = n == 1
|
||||
}
|
||||
if !valid {
|
||||
result = TwoFactorResultInvalid
|
||||
return cache.GetCache().Put(attemptsKey, strconv.Itoa(attempts+1), totpAttemptsTTL)
|
||||
}
|
||||
cache.Remove(attemptsKey)
|
||||
result = TwoFactorResultValid
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
// NewTwoFactor creates a new two-factor authentication token.
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
auth_model "gitea.dev/models/auth"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/cache"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/pquerna/otp/totp"
|
||||
@@ -30,21 +34,42 @@ func TestTwoFactorValidateAndConsumeTOTP(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// first use of a valid passcode succeeds
|
||||
ok, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
result, err := tfa.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, auth_model.TwoFactorResultValid, result)
|
||||
|
||||
// replaying the same passcode is refused, even when still inside the TOTP validity window
|
||||
reloaded, err := auth_model.GetTwoFactorByUID(t.Context(), tfa.UID)
|
||||
require.NoError(t, err)
|
||||
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), passcode)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, auth_model.TwoFactorResultInvalid, result)
|
||||
|
||||
// an invalid passcode is rejected without consuming anything
|
||||
ok, err = reloaded.ValidateAndConsumeTOTP(t.Context(), "000000")
|
||||
var limited atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 10 {
|
||||
wg.Go(func() {
|
||||
result, err := reloaded.ValidateAndConsumeTOTP(t.Context(), "0000000")
|
||||
assert.NoError(t, err)
|
||||
if result == auth_model.TwoFactorResultTooManyAttempts {
|
||||
limited.Add(1)
|
||||
} else {
|
||||
assert.Equal(t, auth_model.TwoFactorResultInvalid, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
assert.EqualValues(t, 6, limited.Load())
|
||||
nextPasscode, err := totp.GenerateCode(key.Secret(), time.Now().Add(30*time.Second))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), nextPasscode)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, auth_model.TwoFactorResultTooManyAttempts, result)
|
||||
|
||||
cache.Remove(fmt.Sprintf("twofa_attempts_%d", tfa.UID))
|
||||
result, err = reloaded.ValidateAndConsumeTOTP(t.Context(), nextPasscode)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, auth_model.TwoFactorResultValid, result)
|
||||
}
|
||||
|
||||
func TestDisableTwoFactor(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
updated_unix: 1546869730
|
||||
|
||||
- id: 2
|
||||
user_id: 3
|
||||
user_id: 14
|
||||
application_id: 1
|
||||
counter: 1
|
||||
scope: "openid"
|
||||
@@ -23,7 +23,7 @@
|
||||
updated_unix: 1546869730
|
||||
|
||||
- id: 4
|
||||
user_id: 99
|
||||
user_id: 2
|
||||
application_id: 2
|
||||
counter: 1
|
||||
scope: "whatever"
|
||||
|
||||
@@ -39,7 +39,7 @@ type CommitStatus struct {
|
||||
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)"`
|
||||
Repo *repo_model.Repository `xorm:"-"`
|
||||
Repo *repo_model.Repository `xorm:"-" json:"-"`
|
||||
State commitstatus.CommitStatusState `xorm:"VARCHAR(7) NOT NULL"`
|
||||
|
||||
// TargetURL points to the commit status page reported by a CI system
|
||||
@@ -49,7 +49,7 @@ type CommitStatus struct {
|
||||
Description string `xorm:"TEXT"`
|
||||
ContextHash string `xorm:"VARCHAR(64) index"`
|
||||
Context string `xorm:"TEXT"`
|
||||
Creator *user_model.User `xorm:"-"`
|
||||
Creator *user_model.User `xorm:"-" json:"-"`
|
||||
CreatorID int64
|
||||
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
@@ -182,7 +182,7 @@ func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (in
|
||||
|
||||
func (status *CommitStatus) loadRepository(ctx context.Context) (err error) {
|
||||
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 {
|
||||
return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err)
|
||||
}
|
||||
@@ -230,11 +230,9 @@ func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context)
|
||||
return "", false
|
||||
}
|
||||
|
||||
if status.Repo == nil {
|
||||
if err := status.loadRepository(ctx); err != nil {
|
||||
log.Error("loadRepository: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if err := status.loadRepository(ctx); err != nil {
|
||||
log.Error("loadRepository: %v", err)
|
||||
return "", false
|
||||
}
|
||||
|
||||
prefix := status.Repo.Link() + "/actions"
|
||||
@@ -572,11 +570,12 @@ func SignCommitsApplyDoerPermission(ctx context.Context, doer *user_model.User,
|
||||
}
|
||||
|
||||
func statusRepoCanReadActions(ctx context.Context, doer *user_model.User, status *CommitStatus) bool {
|
||||
if err := status.loadRepository(ctx); err != nil {
|
||||
log.Error("loadRepository: %v", err)
|
||||
return false
|
||||
}
|
||||
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 {
|
||||
return access_model.Permission{}, err
|
||||
}
|
||||
func(ctx context.Context, _key string) (access_model.Permission, error) {
|
||||
return access_model.GetDoerRepoPermission(ctx, status.Repo, doer)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/commitstatus"
|
||||
"gitea.dev/modules/git"
|
||||
|
||||
@@ -281,8 +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.Empty(t, statuses[1].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)
|
||||
}
|
||||
|
||||
@@ -99,8 +99,8 @@ func (protectBranch *ProtectedBranch) loadGlob() {
|
||||
var err error
|
||||
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
|
||||
if err != nil {
|
||||
log.Warn("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
|
||||
protectBranch.globRule = glob.MustCompile(glob.QuoteMeta(protectBranch.RuleName), '/')
|
||||
log.Debug("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
|
||||
protectBranch.globRule, _ = glob.Compile(glob.QuoteMeta(protectBranch.RuleName), '/')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,9 @@ func (protectBranch *ProtectedBranch) Match(branchName string) bool {
|
||||
if protectBranch.isPlainName {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/glob"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
)
|
||||
|
||||
@@ -55,10 +56,17 @@ func FindRepoProtectedBranchRules(ctx context.Context, repoID int64) (ProtectedB
|
||||
// FindAllMatchedBranches find all matched branches
|
||||
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
|
||||
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++ {
|
||||
brancheNames, err := FindBranchNames(ctx, FindBranchOptions{
|
||||
branchNames, err := FindBranchNames(ctx, FindBranchOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: 100,
|
||||
PageSize: pageSize,
|
||||
Page: page,
|
||||
},
|
||||
RepoID: repoID,
|
||||
@@ -67,14 +75,12 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule := glob.MustCompile(ruleName)
|
||||
|
||||
for _, branch := range brancheNames {
|
||||
for _, branch := range branchNames {
|
||||
if rule.Match(branch) {
|
||||
results = append(results, branch)
|
||||
}
|
||||
}
|
||||
if len(brancheNames) < 100 {
|
||||
if len(branchNames) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// ErrCommentNotExist represents a "CommentNotExist" kind of error.
|
||||
@@ -369,12 +370,19 @@ func (c *Comment) LoadIssue(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
_ xorm.BeforeInsertProcessor = (*Comment)(nil)
|
||||
_ xorm.BeforeUpdateProcessor = (*Comment)(nil)
|
||||
)
|
||||
|
||||
// BeforeInsert will be invoked by XORM before inserting a record
|
||||
func (c *Comment) BeforeInsert() {
|
||||
c.PatchQuoted = c.Patch
|
||||
if !utf8.ValidString(c.Patch) {
|
||||
c.PatchQuoted = strconv.Quote(c.Patch)
|
||||
}
|
||||
|
||||
sanitizeDatabaseStringContent(&c.Content)
|
||||
}
|
||||
|
||||
// BeforeUpdate will be invoked by XORM before updating a record
|
||||
@@ -383,6 +391,8 @@ func (c *Comment) BeforeUpdate() {
|
||||
if !utf8.ValidString(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.
|
||||
@@ -1164,6 +1174,11 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
|
||||
if affected == 0 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
project_model "gitea.dev/models/project"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type Issue struct {
|
||||
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:"-"`
|
||||
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
|
||||
PosterID int64 `xorm:"INDEX"`
|
||||
@@ -75,13 +77,13 @@ type Issue struct {
|
||||
AssigneeID int64 `xorm:"-"`
|
||||
Assignee *user_model.User `xorm:"-"`
|
||||
isAssigneeLoaded bool `xorm:"-"`
|
||||
IsClosed bool `xorm:"INDEX"`
|
||||
IsClosed bool `xorm:"INDEX(repo_closed_pull)"`
|
||||
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:"-"`
|
||||
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
|
||||
|
||||
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
|
||||
@@ -123,6 +125,27 @@ func init() {
|
||||
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
|
||||
func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
|
||||
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.
|
||||
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
|
||||
scope := after
|
||||
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
|
||||
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
|
||||
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
|
||||
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
|
||||
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("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
|
||||
sess.OrderBy("COALESCE((SELECT MIN(label.exclusive_order) FROM issue_label"+
|
||||
" INNER JOIN label ON label.id = issue_label.label_id"+
|
||||
" WHERE issue_label.issue_id = issue.id AND label.exclusive_order <> 0 AND label.name LIKE ?), 2147483647) ASC", scope+"/%").
|
||||
Desc("issue.id")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -218,10 +218,7 @@ func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
|
||||
opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
|
||||
}
|
||||
if opts.AllPublic {
|
||||
if opts.RepoCond == nil {
|
||||
opts.RepoCond = builder.NewCond()
|
||||
}
|
||||
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond())))
|
||||
opts.RepoCond = builder.Or(opts.RepoCond, repo_model.PublicRepoUnderPublicOwnerCond())
|
||||
}
|
||||
if opts.RepoCond != nil {
|
||||
sess.And(opts.RepoCond)
|
||||
@@ -402,22 +399,17 @@ func applyReviewRequestedCondition(sess db.Session, reviewRequestedID int64) {
|
||||
From("team_user").
|
||||
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").
|
||||
From("review").
|
||||
Where(builder.And(
|
||||
builder.Eq{"review.type": ReviewTypeRequest},
|
||||
builder.Or(
|
||||
builder.Eq{"review.reviewer_id": reviewRequestedID},
|
||||
builder.In("review.reviewer_team_id", existInTeamQuery),
|
||||
),
|
||||
builder.In("review.id", maxReview),
|
||||
))
|
||||
Where(builder.Eq{"review.type": ReviewTypeRequest}).
|
||||
And(builder.Or(
|
||||
builder.Eq{"review.reviewer_id": reviewRequestedID},
|
||||
builder.In("review.reviewer_team_id", existInTeamQuery),
|
||||
)).
|
||||
// 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).
|
||||
And(builder.In("issue.id", subQuery))
|
||||
}
|
||||
@@ -477,7 +469,7 @@ func applySubscribedCondition(sess db.Session, subscriberID int64) {
|
||||
Select("repo_id").
|
||||
From("watch").
|
||||
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))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -269,27 +269,23 @@ func UpdateLabel(ctx context.Context, l *Label) error {
|
||||
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
|
||||
}
|
||||
|
||||
// DeleteLabel delete a label
|
||||
func DeleteLabel(ctx context.Context, id, labelID int64) error {
|
||||
l, err := GetLabelByID(ctx, labelID)
|
||||
if err != nil {
|
||||
if IsErrLabelNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// DeleteRepoLabel deletes a repository label.
|
||||
func DeleteRepoLabel(ctx context.Context, repoID, labelID int64) error {
|
||||
return deleteLabel(ctx, labelID, builder.Eq{"repo_id": repoID, "org_id": 0})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if l.BelongsToOrg() && l.OrgID != id {
|
||||
return nil
|
||||
}
|
||||
if l.BelongsToRepo() && l.RepoID != id {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err = db.DeleteByID[Label](ctx, labelID); err != nil {
|
||||
deleted, err := db.GetEngine(ctx).ID(labelID).Where(ownerCond).Delete(new(Label))
|
||||
if err != nil || deleted == 0 {
|
||||
return err
|
||||
} else if _, err = db.GetEngine(ctx).
|
||||
}
|
||||
if _, err = db.GetEngine(ctx).
|
||||
Where("label_id = ?", labelID).
|
||||
Delete(new(IssueLabel)); err != nil {
|
||||
return err
|
||||
|
||||
@@ -320,13 +320,29 @@ func TestUpdateLabel(t *testing.T) {
|
||||
func TestDeleteLabel(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
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})
|
||||
|
||||
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})
|
||||
|
||||
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{})
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,13 @@ func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList)
|
||||
// FindMilestoneOptions contain options to get milestones
|
||||
type FindMilestoneOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
IsClosed optional.Option[bool]
|
||||
Name string
|
||||
SortType string
|
||||
RepoCond builder.Cond
|
||||
RepoIDs []int64
|
||||
RepoID int64
|
||||
IsClosed optional.Option[bool]
|
||||
Name string
|
||||
SortType string
|
||||
RepoCond builder.Cond
|
||||
RepoIDs []int64
|
||||
ProjectID int64
|
||||
}
|
||||
|
||||
func (opts FindMilestoneOptions) ToConds() builder.Cond {
|
||||
@@ -64,6 +65,11 @@ func (opts FindMilestoneOptions) ToConds() builder.Cond {
|
||||
if len(opts.Name) != 0 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func (pr *PullRequest) getReviewedByLines(ctx context.Context, writer io.Writer)
|
||||
reviewersWritten := 0
|
||||
|
||||
for _, review := range reviews {
|
||||
if maxReviewers > 0 && reviewersWritten > maxReviewers {
|
||||
if maxReviewers > 0 && reviewersWritten >= maxReviewers {
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -335,8 +335,8 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
|
||||
"reviewer_team_id": 0,
|
||||
}).
|
||||
GroupBy("issue_id, reviewer_id")
|
||||
// Get latest review of each reviewer, sorted in order they were made
|
||||
if err := db.GetEngine(ctx).In("id", subQuery).OrderBy("review.updated_unix ASC").Find(&reviews); err != nil {
|
||||
// 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).Table("review").Alias("latest").Join("INNER", subQuery, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&reviews); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
|
||||
"reviewer_team_id": 0,
|
||||
}).
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -403,6 +404,10 @@ func testGetApprovers(t *testing.T) {
|
||||
approvers := pr.GetApprovers(t.Context())
|
||||
expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n"
|
||||
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) {
|
||||
|
||||
@@ -285,23 +285,12 @@ func IsOfficialReviewer(ctx context.Context, issue *Issue, reviewer *user_model.
|
||||
}
|
||||
if rule == nil {
|
||||
// if no rule is found, then user with write access can make official reviews
|
||||
err := pr.LoadBaseRepo(ctx)
|
||||
if err != nil {
|
||||
if err := pr.LoadBaseRepo(ctx); err != nil {
|
||||
return false, err
|
||||
}
|
||||
writeAccess, err := access_model.HasAccessUnit(ctx, reviewer, pr.BaseRepo, unit.TypeCode, perm.AccessModeWrite)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return writeAccess, nil
|
||||
return access_model.HasAccessUnit(ctx, reviewer, pr.BaseRepo, unit.TypeCode, perm.AccessModeWrite)
|
||||
}
|
||||
|
||||
official, err := git_model.IsUserOfficialReviewer(ctx, rule, reviewer)
|
||||
if official || err != nil {
|
||||
return official, err
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return git_model.IsUserOfficialReviewer(ctx, rule, reviewer)
|
||||
}
|
||||
|
||||
// IsOfficialReviewerTeam check if reviewer in this team can make official reviews in issue (counts towards required approvals)
|
||||
@@ -313,66 +302,82 @@ func IsOfficialReviewerTeam(ctx context.Context, issue *Issue, team *organizatio
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if pb == nil {
|
||||
return false, nil
|
||||
if pb != nil && pb.EnableApprovalsWhitelist {
|
||||
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
|
||||
}
|
||||
|
||||
if !pb.EnableApprovalsWhitelist {
|
||||
return team.UnitAccessMode(ctx, unit.TypeCode) >= perm.AccessModeWrite, nil
|
||||
}
|
||||
|
||||
return slices.Contains(pb.ApprovalsWhitelistTeamIDs, team.ID), nil
|
||||
return team.UnitAccessMode(ctx, unit.TypeCode) >= perm.AccessModeWrite, nil
|
||||
}
|
||||
|
||||
// RecalculateReviewsOfficial re-evaluates the "official" flag of the latest approve
|
||||
// and reject reviews of an issue against its pull request's current base branch.
|
||||
// It must be called whenever the target branch changes, otherwise an approval that
|
||||
// was official on the previous (possibly unprotected) branch would keep satisfying
|
||||
// the new branch's protection rules.
|
||||
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
|
||||
if err := issue.LoadPullRequest(ctx); err != nil {
|
||||
// updateReviewerOfficial keeps the official flag only on the reviewer's latest approval, rejection or review request, and only if the reviewer is official
|
||||
func updateReviewerOfficial(ctx context.Context, issueID, reviewerID int64, official bool) error {
|
||||
if _, err := db.GetEngine(ctx).Where(builder.Eq{"issue_id": issueID, "reviewer_id": reviewerID}).
|
||||
NoAutoTime().Cols("official").Update(&Review{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !official {
|
||||
return nil
|
||||
}
|
||||
latest, err := GetReviewByIssueIDAndUserID(ctx, issueID, reviewerID)
|
||||
if IsErrReviewNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.GetEngine(ctx).ID(latest.ID).NoAutoTime().Cols("official").Update(&Review{Official: true})
|
||||
return err
|
||||
}
|
||||
|
||||
// Clearing and restoring the official flags must happen atomically, otherwise a
|
||||
// failure in between would leave the reviews without any official flag set.
|
||||
// RecalculateReviewsOfficial re-evaluates the "official" flag of an issue's reviews and review requests
|
||||
// against its pull request's current base branch. It must be called whenever the target branch changes,
|
||||
// otherwise an approval that was official on the previous (possibly unprotected) branch would keep
|
||||
// satisfying the new branch's protection rules.
|
||||
func RecalculateReviewsOfficial(ctx context.Context, issue *Issue) error {
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
// Only the latest approve/reject review of each reviewer counts as official, so
|
||||
// clear the flag on all of them first and restore it only where it still applies.
|
||||
if _, err := db.GetEngine(ctx).
|
||||
Where("issue_id = ?", issue.ID).
|
||||
In("type", ReviewTypeApprove, ReviewTypeReject).
|
||||
Cols("official").
|
||||
Update(&Review{Official: false}); err != nil {
|
||||
if _, err := db.GetEngine(ctx).Where("issue_id = ?", issue.ID).NoAutoTime().Cols("official").Update(&Review{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reviews, err := FindLatestReviews(ctx, FindReviewOptions{
|
||||
Types: []ReviewType{ReviewTypeApprove, ReviewTypeReject},
|
||||
IssueID: issue.ID,
|
||||
})
|
||||
if err != nil {
|
||||
reviewerIDs := make([]int64, 0, 5)
|
||||
if err := db.GetEngine(ctx).Table("review").Cols("reviewer_id").
|
||||
Where(builder.Eq{"issue_id": issue.ID, "original_author_id": 0}).
|
||||
And(builder.In("type", ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest)).
|
||||
And(builder.Neq{"reviewer_id": 0}).
|
||||
Distinct("reviewer_id").Find(&reviewerIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, review := range reviews {
|
||||
if err := review.LoadReviewer(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if review.Reviewer == nil {
|
||||
continue
|
||||
}
|
||||
official, err := IsOfficialReviewer(ctx, issue, review.Reviewer)
|
||||
for _, reviewerID := range reviewerIDs {
|
||||
_, reviewer, err := user_model.GetPossibleUserByID(ctx, reviewerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if official {
|
||||
if _, err := db.GetEngine(ctx).ID(review.ID).Cols("official").Update(&Review{Official: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
official, err := IsOfficialReviewer(ctx, issue, reviewer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := updateReviewerOfficial(ctx, issue.ID, reviewerID, official); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
teamRequests := make([]*Review, 0, 5)
|
||||
if err := db.GetEngine(ctx).Where(builder.Eq{"issue_id": issue.ID, "type": ReviewTypeRequest}).
|
||||
And(builder.Gt{"reviewer_team_id": 0}).Find(&teamRequests); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, request := range teamRequests {
|
||||
if err := request.LoadReviewerTeam(ctx); organization.IsErrTeamNotExist(err) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
official, err := IsOfficialReviewerTeam(ctx, issue, request.ReviewerTeam)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.GetEngine(ctx).ID(request.ID).NoAutoTime().Cols("official").Update(&Review{Official: official}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -474,7 +479,13 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
|
||||
defer committer.Close()
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
isApproveOrReject := reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject
|
||||
official := false
|
||||
if isApproveOrReject {
|
||||
if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
review, err := GetCurrentReview(ctx, doer, issue)
|
||||
if err != nil {
|
||||
@@ -486,16 +497,6 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
|
||||
return nil, nil, ContentEmptyErr{}
|
||||
}
|
||||
|
||||
if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject {
|
||||
// Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared
|
||||
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// No current review. Create a new one!
|
||||
if review, err = CreateReview(ctx, CreateReviewOptions{
|
||||
Type: reviewType,
|
||||
@@ -516,16 +517,6 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
|
||||
return nil, nil, ContentEmptyErr{}
|
||||
}
|
||||
|
||||
if reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject {
|
||||
// Only reviewers latest review of type approve and reject shall count as "official", so existing reviews needs to be cleared
|
||||
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, doer.ID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
review.Official = official
|
||||
review.Issue = issue
|
||||
review.Content = content
|
||||
@@ -546,6 +537,16 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
|
||||
}
|
||||
}
|
||||
|
||||
if isApproveOrReject {
|
||||
if err := updateReviewerOfficial(ctx, issue.ID, doer.ID, official); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if reviewType == ReviewTypePending {
|
||||
return review, nil, committer.Commit()
|
||||
}
|
||||
|
||||
comm, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeReview,
|
||||
Doer: doer,
|
||||
@@ -560,7 +561,7 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi
|
||||
}
|
||||
|
||||
// try to remove team review request if need
|
||||
if issue.Repo.Owner.IsOrganization() && (reviewType == ReviewTypeApprove || reviewType == ReviewTypeReject) {
|
||||
if issue.Repo.Owner.IsOrganization() && isApproveOrReject {
|
||||
teamReviewRequests := make([]*Review, 0, 10)
|
||||
if err := sess.SQL("SELECT * FROM review WHERE issue_id = ? AND reviewer_team_id > 0 AND type = ?", issue.ID, ReviewTypeRequest).Find(&teamReviewRequests); err != nil {
|
||||
return nil, nil, err
|
||||
@@ -698,8 +699,6 @@ func InsertReviews(ctx context.Context, reviews []*Review) error {
|
||||
// AddReviewRequest add a review request from one reviewer
|
||||
func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User, isCodeOwners bool) (*Comment, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
|
||||
sess := db.GetEngine(ctx)
|
||||
|
||||
review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, reviewer.ID)
|
||||
if err != nil && !IsErrReviewNotExist(err) {
|
||||
return nil, err
|
||||
@@ -725,15 +724,9 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo
|
||||
}
|
||||
}
|
||||
|
||||
// if the reviewer is an official reviewer,
|
||||
// remove the official flag in the all previous reviews
|
||||
official, err := IsOfficialReviewer(ctx, issue, reviewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if official {
|
||||
if _, err := sess.Exec("UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_id=?", false, issue.ID, reviewer.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
review, err = CreateReview(ctx, CreateReviewOptions{
|
||||
@@ -747,6 +740,10 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := updateReviewerOfficial(ctx, issue.ID, reviewer.ID, official); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comment, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeReviewRequest,
|
||||
Doer: doer,
|
||||
@@ -787,10 +784,9 @@ func RemoveReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user
|
||||
official, err := IsOfficialReviewer(ctx, issue, reviewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if official {
|
||||
if err := restoreLatestOfficialReview(ctx, issue.ID, reviewer.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := updateReviewerOfficial(ctx, issue.ID, reviewer.ID, official); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return CreateComment(ctx, &CreateCommentOptions{
|
||||
@@ -804,22 +800,6 @@ func RemoveReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user
|
||||
})
|
||||
}
|
||||
|
||||
// Recalculate the latest official review for reviewer
|
||||
func restoreLatestOfficialReview(ctx context.Context, issueID, reviewerID int64) error {
|
||||
review, err := GetReviewByIssueIDAndUserID(ctx, issueID, reviewerID)
|
||||
if err != nil && !IsErrReviewNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if review != nil {
|
||||
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTeamReviewRequest add a review request from one team
|
||||
func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User, isCodeOwners bool) (*Comment, error) {
|
||||
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
|
||||
@@ -836,10 +816,6 @@ func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organizat
|
||||
official, err := IsOfficialReviewerTeam(ctx, issue, reviewer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("isOfficialReviewerTeam(): %w", err)
|
||||
} else if !official {
|
||||
if official, err = IsOfficialReviewer(ctx, issue, doer); err != nil {
|
||||
return nil, fmt.Errorf("isOfficialReviewer(): %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if review, err = CreateReview(ctx, CreateReviewOptions{
|
||||
@@ -852,12 +828,6 @@ func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organizat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if official {
|
||||
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE issue_id=? AND reviewer_team_id=?", false, issue.ID, reviewer.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
comment, err := CreateComment(ctx, &CreateCommentOptions{
|
||||
Type: CommentTypeReviewRequest,
|
||||
Doer: doer,
|
||||
@@ -892,25 +862,6 @@ func RemoveTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organi
|
||||
return nil, err
|
||||
}
|
||||
|
||||
official, err := IsOfficialReviewerTeam(ctx, issue, reviewer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("isOfficialReviewerTeam(): %w", err)
|
||||
}
|
||||
|
||||
if official {
|
||||
// recalculate which is the latest official review from that team
|
||||
review, err := GetReviewByIssueIDAndUserID(ctx, issue.ID, -reviewer.ID)
|
||||
if err != nil && !IsErrReviewNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if review != nil {
|
||||
if _, err := db.Exec(ctx, "UPDATE `review` SET official=? WHERE id=?", true, review.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if doer == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1037,10 +988,8 @@ func DeleteReview(ctx context.Context, r *Review) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if r.Official {
|
||||
if err := restoreLatestOfficialReview(ctx, r.IssueID, r.ReviewerID); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Official && r.OriginalAuthorID == 0 {
|
||||
return updateReviewerOfficial(ctx, r.IssueID, r.ReviewerID, true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/organization"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
@@ -440,3 +441,17 @@ func TestRecalculateReviewsOfficial(t *testing.T) {
|
||||
review = unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: review.ID})
|
||||
assert.True(t, review.Official)
|
||||
}
|
||||
|
||||
func TestTeamReviewRequestOfficial(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12})
|
||||
assert.NoError(t, issue.LoadRepo(t.Context()))
|
||||
comment, err := issues_model.AddTeamReviewRequest(t.Context(), issue, unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2}), &user_model.User{ID: 2}, false)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: comment.ReviewID}).Official)
|
||||
|
||||
assert.NoError(t, db.Insert(t.Context(), &git_model.ProtectedBranch{RepoID: issue.RepoID, RuleName: "master", EnableApprovalsWhitelist: true}))
|
||||
assert.NoError(t, issues_model.RecalculateReviewsOfficial(t.Context(), issue))
|
||||
assert.False(t, unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: comment.ReviewID}).Official)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder {
|
||||
|
||||
func (opts FindOrgOptions) ToConds() builder.Cond {
|
||||
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)))
|
||||
}
|
||||
// public=0, limited=1, private=2
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// TeamRepo represents an team-repository relation.
|
||||
// TeamRepo represents an org-team-repository relation.
|
||||
type TeamRepo struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"INDEX"`
|
||||
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.
|
||||
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// TeamUser represents an team-user relation.
|
||||
// TeamUser represents a team-user relation.
|
||||
type TeamUser struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
OrgID int64 `xorm:"INDEX"`
|
||||
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.
|
||||
|
||||
@@ -122,8 +122,8 @@ func GetTotalUnreferencedBlobSize(ctx context.Context) (int64, error) {
|
||||
}
|
||||
|
||||
// IsBlobAccessibleForUser tests if the user has access to the blob
|
||||
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User) (bool, error) {
|
||||
if user.IsAdmin {
|
||||
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User, publicOnly bool) (bool, error) {
|
||||
if user.IsAdmin && !publicOnly {
|
||||
return true, nil
|
||||
}
|
||||
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}.
|
||||
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).
|
||||
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"})
|
||||
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)
|
||||
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)
|
||||
assert.False(t, accessible)
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ const (
|
||||
// PackageProperty represents a property of a package, version or file
|
||||
type PackageProperty struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
RefType PropertyType `xorm:"INDEX NOT NULL"`
|
||||
RefID int64 `xorm:"INDEX NOT NULL"`
|
||||
Name string `xorm:"INDEX NOT NULL"`
|
||||
RefType PropertyType `xorm:"NOT NULL"`
|
||||
RefID int64 `xorm:"INDEX(ref_id_name) NOT NULL"`
|
||||
Name string `xorm:"INDEX(ref_id_name) NOT NULL"`
|
||||
Value string `xorm:"LONGTEXT NOT NULL"`
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
type Access struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"UNIQUE(s)"`
|
||||
RepoID int64 `xorm:"UNIQUE(s)"`
|
||||
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||
Mode perm.AccessMode
|
||||
}
|
||||
|
||||
|
||||
@@ -506,13 +506,16 @@ func GetIndividualUserRepoPermission(ctx context.Context, repo *repo_model.Repos
|
||||
}
|
||||
}
|
||||
|
||||
// if user in an owner team
|
||||
// Apply repository-level access from teams.
|
||||
for _, team := range perm.orgRepoTeams {
|
||||
if team.IsOwnerTeam() || team.AccessMode == perm_model.AccessModeOwner {
|
||||
perm.AccessMode = perm_model.AccessModeOwner
|
||||
perm.unitsMode = nil
|
||||
return perm, nil
|
||||
}
|
||||
if team.AccessMode == perm_model.AccessModeAdmin {
|
||||
perm.AccessMode = max(perm.AccessMode, perm_model.AccessModeAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
for _, u := range repo.Units {
|
||||
|
||||
@@ -236,6 +236,32 @@ func testGetIndividualUserRepoPermission(t *testing.T) {
|
||||
require.Empty(t, users)
|
||||
})
|
||||
|
||||
t.Run("DoerWithAdminTeamWithoutAccessRow", func(t *testing.T) {
|
||||
_, err := db.GetEngine(ctx).ID(team.ID).Cols("authorize").Update(&organization.Team{AccessMode: perm_model.AccessModeAdmin})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_, err := db.GetEngine(ctx).ID(team.ID).Cols("authorize").Update(&organization.Team{AccessMode: perm_model.AccessModeNone})
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
for _, repo := range []*repo_model.Repository{repo3, repo32} {
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, repo, user)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, perm_model.AccessModeAdmin, perm.AccessMode)
|
||||
assert.True(t, perm.IsAdmin())
|
||||
assert.False(t, perm.IsOwner())
|
||||
for _, repoUnit := range repo.Units {
|
||||
assert.Equal(t, perm_model.AccessModeAdmin, perm.UnitAccessMode(repoUnit.Type))
|
||||
}
|
||||
}
|
||||
|
||||
unrelatedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
|
||||
require.Equal(t, org.ID, unrelatedRepo.OwnerID)
|
||||
perm, err := GetIndividualUserRepoPermission(ctx, unrelatedRepo, user)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, perm.IsAdmin())
|
||||
})
|
||||
|
||||
require.NoError(t, db.Insert(ctx, repo_model.Collaboration{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite}))
|
||||
require.NoError(t, db.Insert(ctx, Access{RepoID: repo3.ID, UserID: user.ID, Mode: perm_model.AccessModeWrite}))
|
||||
t.Run("DoerWithReadIssueTeamAndWriteCollaboratorOnPrivateRepo", func(t *testing.T) {
|
||||
|
||||
@@ -225,7 +225,7 @@ func (opts SearchOptions) ToConds() builder.Cond {
|
||||
if opts.Type > 0 {
|
||||
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})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
_ "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>`, 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) {
|
||||
|
||||
@@ -42,6 +42,10 @@ func init() {
|
||||
db.RegisterModel(new(Attachment))
|
||||
}
|
||||
|
||||
func (a *Attachment) IsLinked() bool {
|
||||
return a.IssueID > 0 || a.ReleaseID > 0 || a.CommentID > 0
|
||||
}
|
||||
|
||||
// IncreaseDownloadCount is update download count + 1
|
||||
func (a *Attachment) IncreaseDownloadCount(ctx context.Context) error {
|
||||
// Update download count.
|
||||
@@ -133,6 +137,9 @@ func ExistAttachmentsByUUID(ctx context.Context, uuid string) (bool, error) {
|
||||
|
||||
// GetAttachmentsByIssueID returns all attachments of an issue.
|
||||
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)
|
||||
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.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)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, attachments, 2)
|
||||
|
||||
@@ -151,7 +151,7 @@ func UpdateLanguageStats(ctx context.Context, repo *Repository, commitID string,
|
||||
var topLang string
|
||||
var s int64
|
||||
for lang, size := range stats {
|
||||
if size > s {
|
||||
if size > s || (size == s && lang < topLang) {
|
||||
s = size
|
||||
topLang = lang
|
||||
}
|
||||
|
||||
28
models/repo/language_stats_test.go
Normal file
28
models/repo/language_stats_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateLanguageStatsPrimaryTie(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
|
||||
require.NoError(t, repo_model.UpdateLanguageStats(t.Context(), repo, "1234567890", map[string]int64{"Go": 10, "C": 10}))
|
||||
|
||||
stats, err := repo_model.GetLanguageStats(t.Context(), repo)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, stats, 2)
|
||||
for _, stat := range stats {
|
||||
assert.Equal(t, stat.Language == "C", stat.IsPrimary, stat.Language)
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,10 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
|
||||
}
|
||||
|
||||
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 {
|
||||
attachments[i].RepoID = rel.RepoID
|
||||
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 {
|
||||
return util.NewPermissionDeniedErrorf("attachment belongs to different repository")
|
||||
}
|
||||
|
||||
if attachments[i].ReleaseID != 0 {
|
||||
return util.NewPermissionDeniedErrorf("release permission denied")
|
||||
}
|
||||
attachments[i].ReleaseID = releaseID
|
||||
// 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 {
|
||||
@@ -357,21 +357,21 @@ func GetLatestReleaseByRepoID(ctx context.Context, repoID int64) (*Release, erro
|
||||
}
|
||||
|
||||
type releaseMetaSearch struct {
|
||||
ID []int64
|
||||
Rel []*Release
|
||||
IDs []int64
|
||||
Releases []*Release
|
||||
}
|
||||
|
||||
func (s releaseMetaSearch) Len() int {
|
||||
return len(s.ID)
|
||||
return len(s.IDs)
|
||||
}
|
||||
|
||||
func (s releaseMetaSearch) Swap(i, j int) {
|
||||
s.ID[i], s.ID[j] = s.ID[j], s.ID[i]
|
||||
s.Rel[i], s.Rel[j] = s.Rel[j], s.Rel[i]
|
||||
s.IDs[i], s.IDs[j] = s.IDs[j], s.IDs[i]
|
||||
s.Releases[i], s.Releases[j] = s.Releases[j], s.Releases[i]
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -396,19 +396,26 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
|
||||
// then merge join them
|
||||
|
||||
// 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
|
||||
for index, element := range rels {
|
||||
element.Attachments = []*Attachment{}
|
||||
sortedRels.ID[index] = element.ID
|
||||
sortedRels.Rel[index] = element
|
||||
for _, rel := range rels {
|
||||
// if a provided "Release" has no ID, don't query it, otherwise, the "IN" query will return all other attachments for the repo
|
||||
if rel.ID == 0 {
|
||||
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)
|
||||
|
||||
// Select attachments
|
||||
err = db.GetEngine(ctx).
|
||||
Asc("release_id", "name").
|
||||
In("release_id", sortedRels.ID).
|
||||
In("release_id", sortedRels.IDs).
|
||||
Find(&attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -417,14 +424,14 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
|
||||
// merge join
|
||||
currentIndex := 0
|
||||
for _, attachment := range attachments {
|
||||
for sortedRels.ID[currentIndex] < attachment.ReleaseID {
|
||||
for sortedRels.IDs[currentIndex] < attachment.ReleaseID {
|
||||
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
|
||||
for _, release := range rels {
|
||||
for _, release := range sortedRels.Releases {
|
||||
// If we have no Repo, we don't need to execute this loop
|
||||
if release.Repo == nil {
|
||||
continue
|
||||
|
||||
@@ -53,6 +53,18 @@ func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) {
|
||||
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) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
||||
@@ -494,10 +494,9 @@ func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]strin
|
||||
repo.MustOwner(ctx)
|
||||
if repo.Owner.IsOrganization() {
|
||||
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").
|
||||
Where("team_repo.repo_id = ?", repo.ID).
|
||||
Select("team.lower_name").
|
||||
OrderBy("team.lower_name").
|
||||
Find(&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.
|
||||
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
|
||||
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 {
|
||||
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("%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()
|
||||
|
||||
if opts.Private {
|
||||
if opts.Actor != nil && !opts.Actor.IsAdmin && opts.Actor.ID != opts.OwnerID {
|
||||
// OK we're in the context of a User
|
||||
if opts.Actor != nil && !opts.Actor.IsAdmin {
|
||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||
}
|
||||
} else {
|
||||
@@ -762,7 +761,8 @@ func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user
|
||||
func PublicRepoUnderPublicOwnerCond() builder.Cond {
|
||||
return builder.And(
|
||||
builder.Eq{"`repository`.is_private": false},
|
||||
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePublic})),
|
||||
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"))
|
||||
setting.Repository.UseCompatSSHURI = true
|
||||
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
|
||||
setting.SSH.Port = 123
|
||||
setting.Repository.UseCompatSSHURI = false
|
||||
|
||||
@@ -77,7 +77,7 @@ func IsErrRepoAlreadyExist(err error) bool {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -20,9 +20,10 @@ type WatchMode int8
|
||||
const (
|
||||
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
|
||||
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
|
||||
@@ -38,7 +39,7 @@ const (
|
||||
type Watch struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"UNIQUE(watch)"`
|
||||
RepoID int64 `xorm:"UNIQUE(watch)"`
|
||||
RepoID int64 `xorm:"UNIQUE(watch) INDEX"`
|
||||
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
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
|
||||
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 {
|
||||
@@ -111,6 +112,9 @@ func IsWatchingRepo(ctx context.Context, userID, repoID int64) bool {
|
||||
}
|
||||
|
||||
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
|
||||
if watch.UserID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if watch.Mode == mode {
|
||||
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) {
|
||||
return user_model.ErrBlockedUser
|
||||
}
|
||||
if watch.Mode == WatchModeCustom { // keep the events the user picked
|
||||
return nil
|
||||
}
|
||||
|
||||
return watchRepoByMode(ctx, watch, WatchModeNormal)
|
||||
}
|
||||
@@ -189,7 +196,7 @@ func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repo
|
||||
if err != nil {
|
||||
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).
|
||||
Cols("include_pull_requests", "include_issues", "include_releases").
|
||||
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
|
||||
// User permissions must be verified elsewhere if required
|
||||
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)
|
||||
var watchColName string
|
||||
switch watchType {
|
||||
@@ -259,12 +276,19 @@ func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType)
|
||||
default:
|
||||
panic("invalid WatchType")
|
||||
}
|
||||
return ids, db.GetEngine(ctx).Table("watch").
|
||||
sess := db.GetEngine(ctx).Table("watch").
|
||||
Where("watch.repo_id=?", repoID).
|
||||
And("watch.mode<>?", WatchModeDont).
|
||||
And(builder.Eq{watchColName: true}).
|
||||
Select("user_id").
|
||||
Find(&ids)
|
||||
And(builder.Eq{watchColName: true})
|
||||
if customOnly { // matches SelectedMode() == "custom"
|
||||
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.
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestGetWatchers(t *testing.T) {
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
|
||||
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)
|
||||
for _, watch := range watches {
|
||||
assert.Equal(t, repo.ID, watch.RepoID)
|
||||
@@ -90,24 +90,30 @@ func TestWatchIfAuto(t *testing.T) {
|
||||
|
||||
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))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
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))
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, watchers, prevCount)
|
||||
|
||||
// Should add watch
|
||||
// Should add watch (write operation)
|
||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 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 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
|
||||
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
|
||||
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 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{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{
|
||||
repo_model.WatchPullRequests: {1, 4, 9, 11},
|
||||
@@ -157,6 +163,20 @@ func TestWatchOptions(t *testing.T) {
|
||||
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
|
||||
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
|
||||
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, "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.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())
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -103,6 +104,10 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
|
||||
require.NoError(t, err, "creating fixture %s", fixturePath)
|
||||
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)) {
|
||||
if slices.Contains(ignoredHeaders, strings.ToLower(name)) {
|
||||
continue
|
||||
@@ -125,14 +130,21 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
|
||||
|
||||
replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL)
|
||||
headers, body, _ := strings.Cut(replayed, "\n\n")
|
||||
code := http.StatusOK
|
||||
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, ": ")
|
||||
if !ok || strings.EqualFold(name, "Content-Length") {
|
||||
continue
|
||||
}
|
||||
w.Header().Set(name, value)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.WriteHeader(code)
|
||||
_, err = w.Write([]byte(body))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
@@ -151,16 +151,28 @@ func TestListEmails(t *testing.T) {
|
||||
|
||||
func TestEmailAddressValidate(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"root@localhost": true,
|
||||
"user@[192.168.1.2]": true,
|
||||
"@a": false,
|
||||
"abc@gmail.com": true,
|
||||
"abc@gmail.com\n": false,
|
||||
"": 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.": false,
|
||||
"abc@gmail.com-": false,
|
||||
"abc@gmail.com\n": false,
|
||||
"abc@gmail..com": false,
|
||||
"abc@gmail com": false,
|
||||
"abc@gmail*com": false,
|
||||
|
||||
"Foo <foo@bar.com>": false,
|
||||
"abc@gmail.com (x)": false,
|
||||
"jürgen@example.com": false,
|
||||
"a@foo_bar.com": false,
|
||||
"jürgen@example.com": false, // utf8 address is not supported yet
|
||||
}
|
||||
for tc, isValid := range cases {
|
||||
t.Run(tc, func(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"xorm.io/builder"
|
||||
)
|
||||
@@ -32,6 +33,9 @@ func IsFollowing(ctx context.Context, userID, followID int64) bool {
|
||||
|
||||
// FollowUser marks someone be another's follower.
|
||||
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) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -68,13 +68,20 @@ const (
|
||||
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
|
||||
func (t UserType) DisplayName() string {
|
||||
switch t {
|
||||
case UserTypeOrganization, UserTypeOrganizationReserved:
|
||||
return "Organization"
|
||||
case UserTypeUserReserved:
|
||||
return "Reserved"
|
||||
case UserTypeBot:
|
||||
return "Bot"
|
||||
case UserTypeRemoteUser:
|
||||
return "Remote"
|
||||
default:
|
||||
return "User"
|
||||
}
|
||||
@@ -250,10 +257,10 @@ func (u *User) GetEmail() string {
|
||||
return u.Email
|
||||
}
|
||||
|
||||
// GetAllUsers returns a slice of all individual users found in DB.
|
||||
func GetAllUsers(ctx context.Context) ([]*User, error) {
|
||||
users := make([]*User, 0)
|
||||
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users)
|
||||
// GetAllUsersForAdminListing returns a slice of all users of AdminListedUserTypes found in DB.
|
||||
func GetAllUsersForAdminListing(ctx context.Context) (users []*User, err error) {
|
||||
err = db.GetEngine(ctx).OrderBy("id").In("type", AdminListedUserTypes).Find(&users)
|
||||
return users, err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
func (u *User) IsTypeBot() bool {
|
||||
return u.Type == UserTypeBot
|
||||
@@ -1021,7 +1040,8 @@ func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, er
|
||||
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) {
|
||||
u := new(User)
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
@@ -1046,6 +1067,13 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
||||
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
|
||||
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
|
||||
if id < 0 {
|
||||
@@ -1082,6 +1110,23 @@ func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
||||
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) {
|
||||
u := &User{}
|
||||
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)
|
||||
for _, email := range emails {
|
||||
emailLower := strings.ToLower(email)
|
||||
if emailLower == "" {
|
||||
continue // a commit without an author email names no user
|
||||
}
|
||||
needCheckEmails.Add(emailLower)
|
||||
if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
|
||||
name, id := parseLocalPartToNameID(localPart)
|
||||
|
||||
@@ -95,6 +95,12 @@ func TestUserEmails(t *testing.T) {
|
||||
setting.Service.NoReplyAddress = "example.com"
|
||||
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) {
|
||||
testGetUserByEmail := func(t *testing.T, email string, uid int64) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
actions_model "gitea.dev/models/actions"
|
||||
"gitea.dev/models/db"
|
||||
git_model "gitea.dev/models/git"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
@@ -36,19 +35,11 @@ func GetCommitActionsStatusMap(ctx context.Context, statuses []*git_model.Commit
|
||||
return nil
|
||||
}
|
||||
statusByJobID := make(map[int64]*git_model.CommitStatus)
|
||||
repoByID := make(map[int64]*repo_model.Repository)
|
||||
for _, status := range statuses {
|
||||
if status == nil || status.TargetURL == "" {
|
||||
continue
|
||||
}
|
||||
if status.Repo == nil {
|
||||
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 {
|
||||
if _, jobID, ok := status.ParseGiteaActionsTargetURL(ctx); ok {
|
||||
statusByJobID[jobID] = status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -65,44 +64,31 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
|
||||
m := map[string]*Job{
|
||||
id: job,
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buf)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(m); err != nil {
|
||||
out, err := marshalYAML(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoder.Close()
|
||||
|
||||
node := yaml.Node{}
|
||||
if err := yaml.Unmarshal(buf.Bytes(), &node); err != nil {
|
||||
if err := yaml.Unmarshal(out, &node); err != nil {
|
||||
return err
|
||||
}
|
||||
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]
|
||||
return nil
|
||||
}
|
||||
|
||||
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.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
|
||||
if err := enc.Encode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
return marshalYAML(&payload)
|
||||
}
|
||||
|
||||
// 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 yaml.Dump(v, yaml.WithV3Defaults(), yaml.WithIndent(2), yaml.WithLineWidth(-1))
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
@@ -188,29 +174,18 @@ func (j *Job) DisplayName() string {
|
||||
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 {
|
||||
ID string `yaml:"id,omitempty"`
|
||||
If yaml.Node `yaml:"if,omitempty"`
|
||||
Name BlockSafeString `yaml:"name,omitempty"`
|
||||
Uses string `yaml:"uses,omitempty"`
|
||||
Run BlockSafeString `yaml:"run,omitempty"`
|
||||
WorkingDirectory string `yaml:"working-directory,omitempty"`
|
||||
Shell string `yaml:"shell,omitempty"`
|
||||
Env yaml.Node `yaml:"env,omitempty"`
|
||||
With yaml.Node `yaml:"with,omitempty"`
|
||||
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
|
||||
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
|
||||
ID string `yaml:"id,omitempty"`
|
||||
If yaml.Node `yaml:"if,omitempty"`
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Uses string `yaml:"uses,omitempty"`
|
||||
Run string `yaml:"run,omitempty"`
|
||||
WorkingDirectory string `yaml:"working-directory,omitempty"`
|
||||
Shell string `yaml:"shell,omitempty"`
|
||||
Env yaml.Node `yaml:"env,omitempty"`
|
||||
With yaml.Node `yaml:"with,omitempty"`
|
||||
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
|
||||
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalYAML canonicalizes booleans like continue-on-error
|
||||
@@ -232,9 +207,9 @@ func (s *Step) String() string {
|
||||
}
|
||||
return (&model.Step{
|
||||
ID: s.ID,
|
||||
Name: string(s.Name),
|
||||
Name: s.Name,
|
||||
Uses: s.Uses,
|
||||
Run: string(s.Run),
|
||||
Run: s.Run,
|
||||
}).String()
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
_, origJob := sws[0].Job()
|
||||
require.Len(t, origJob.Steps, 1)
|
||||
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()
|
||||
require.NoError(t, err)
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
// the round-trip must preserve the run block byte-for-byte
|
||||
_, gotJob := roundTripped[0].Job()
|
||||
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.
|
||||
@@ -104,3 +104,37 @@ jobs:
|
||||
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
|
||||
steps:
|
||||
- id: extract_tag
|
||||
name: "\nExtract tag for variant"
|
||||
run: "\necho Test\n"
|
||||
name: |-
|
||||
|
||||
Extract tag for variant
|
||||
run: |
|
||||
|
||||
echo Test
|
||||
|
||||
@@ -532,7 +532,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
|
||||
}
|
||||
|
||||
for _, val := range vals {
|
||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
||||
if matchActivityType(val, actions...) {
|
||||
matchTimes++
|
||||
break
|
||||
}
|
||||
@@ -575,7 +575,7 @@ func matchPullRequestEvent(ctx context.Context, gitRepo *git.Repository, commit
|
||||
}
|
||||
log.Trace("matching pull_request %s with %v", action, vals)
|
||||
for _, val := range vals {
|
||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
||||
if matchActivityType(val, string(action)) {
|
||||
activityTypeMatched = true
|
||||
matchTimes++
|
||||
break
|
||||
@@ -678,7 +678,7 @@ func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *j
|
||||
// NONE
|
||||
|
||||
for _, val := range vals {
|
||||
if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) {
|
||||
if matchActivityType(val, string(issueCommentPayload.Action)) {
|
||||
matchTimes++
|
||||
break
|
||||
}
|
||||
@@ -718,7 +718,7 @@ func matchPullRequestReviewEvent(prPayload *api.PullRequestPayload, evt *jobpars
|
||||
}
|
||||
|
||||
for _, val := range vals {
|
||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
||||
if matchActivityType(val, actions...) {
|
||||
matchTimes++
|
||||
break
|
||||
}
|
||||
@@ -758,7 +758,7 @@ func matchPullRequestReviewCommentEvent(prPayload *api.PullRequestPayload, evt *
|
||||
}
|
||||
|
||||
for _, val := range vals {
|
||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
||||
if matchActivityType(val, actions...) {
|
||||
matchTimes++
|
||||
break
|
||||
}
|
||||
@@ -795,7 +795,7 @@ func matchReleaseEvent(payload *api.ReleasePayload, evt *jobparser.Event) bool {
|
||||
action = "edited"
|
||||
}
|
||||
for _, val := range vals {
|
||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
||||
if matchActivityType(val, string(action)) {
|
||||
matchTimes++
|
||||
break
|
||||
}
|
||||
@@ -832,7 +832,7 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
|
||||
action = "published"
|
||||
}
|
||||
for _, val := range vals {
|
||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
||||
if matchActivityType(val, string(action)) {
|
||||
matchTimes++
|
||||
break
|
||||
}
|
||||
@@ -857,7 +857,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
|
||||
case "types":
|
||||
action := payload.Action
|
||||
for _, val := range vals {
|
||||
if glob.MustCompile(val, '/').Match(action) {
|
||||
if matchActivityType(val, action) {
|
||||
matchTimes++
|
||||
break
|
||||
}
|
||||
@@ -893,3 +893,13 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
|
||||
}
|
||||
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]",
|
||||
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",
|
||||
triggedEvent: webhook_module.HookEventPackage,
|
||||
|
||||
@@ -102,11 +102,11 @@ func NewEmbeddedFS(data []byte) fs.ReadDirFS {
|
||||
efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)}
|
||||
efs.meta = sync.OnceValue(func() *EmbeddedMeta {
|
||||
var meta EmbeddedMeta
|
||||
p := bytes.LastIndexByte(data, '\n')
|
||||
if p < 0 {
|
||||
_, metaJSON, ok := bytes.CutLast(data, []byte{'\n'})
|
||||
if !ok {
|
||||
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")
|
||||
}
|
||||
return &meta
|
||||
|
||||
@@ -32,6 +32,14 @@ func Init() {
|
||||
RPID: setting.AppDomain,
|
||||
RPOrigins: []string{appURL},
|
||||
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"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"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.AppName, WebAuthn.Config.RPDisplayName)
|
||||
assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins)
|
||||
assert.Equal(t, protocol.UnsolicitedOutputPolicyIgnore, WebAuthn.Config.ExtensionsUnsolicitedOutputPolicy)
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ const (
|
||||
EmailAvatarLink = "email_avatar_link"
|
||||
UserEmailAddresses = "user_email_addresses"
|
||||
GPGKeyWithSubKeys = "gpg_key_with_subkeys"
|
||||
Repository = "repository"
|
||||
RepoUserPermission = "repo_user_permission"
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ func Lookup(label string) (e encoding.Encoding, name string) {
|
||||
|
||||
type ConvertOpts struct {
|
||||
KeepBOM bool
|
||||
ErrorReplacement []byte
|
||||
ErrorReplacement []byte // leave it empty to remove the invalid bytes
|
||||
ErrorReturnOrigin bool
|
||||
}
|
||||
|
||||
@@ -99,7 +99,12 @@ func ToUTF8DropErrors(content []byte) []byte {
|
||||
func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
||||
charsetLabel, _ := DetectEncoding(content)
|
||||
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)
|
||||
@@ -123,11 +128,7 @@ func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
||||
if opts.ErrorReturnOrigin {
|
||||
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
|
||||
}
|
||||
return maybeRemoveBOM(decoded, opts)
|
||||
|
||||
@@ -171,6 +171,8 @@ func TestToUTF8DropErrors(t *testing.T) {
|
||||
|
||||
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00})
|
||||
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
|
||||
|
||||
assert.Equal(t, "caf ", string(ToUTF8DropErrors([]byte("caf\xe9"))))
|
||||
}
|
||||
|
||||
func TestDetectEncoding(t *testing.T) {
|
||||
|
||||
@@ -55,11 +55,9 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
|
||||
es.allowed = opts[0].Allowed
|
||||
}
|
||||
|
||||
readCount := 0
|
||||
lastIsTag := false
|
||||
isContentBegin := true
|
||||
for {
|
||||
parts, partInTag, err := es.readRunes()
|
||||
readCount++
|
||||
if err == io.EOF {
|
||||
return es.escaped, nil
|
||||
} else if err != nil {
|
||||
@@ -67,17 +65,14 @@ func escapeStream(locale translation.Locale, in io.Reader, out io.Writer, opts .
|
||||
}
|
||||
for i, part := range parts {
|
||||
if partInTag[i] {
|
||||
lastIsTag = true
|
||||
isContentBegin = true
|
||||
es.trackHtmlTag(part)
|
||||
if _, err := out.Write(part); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// if last part is tag, then this part is content begin
|
||||
// if the content is the first part of the first read, then it's also content begin
|
||||
isContentBegin := lastIsTag || (readCount == 1 && i == 0)
|
||||
lastIsTag = false
|
||||
if isContentBegin {
|
||||
isContentBegin = false
|
||||
if part, err = es.trimAndWriteBom(part); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -236,18 +231,23 @@ func (e *htmlChunkReader) readRunes() (parts [][]byte, partInTag []bool, _ error
|
||||
copy(curPart, e.readBuf[pos:curPartEnd])
|
||||
// now we get the curPart bytes, but we can't directly use it, the last rune in it might have been cut
|
||||
// try to decode the last rune, if it's invalid, then we cut the last byte and try again until we get a valid rune or no byte left
|
||||
for i := curPartLen - 1; i >= 0; i-- {
|
||||
last, lastSize := utf8.DecodeRune(curPart[i:])
|
||||
if last == utf8.RuneError && lastSize == 1 {
|
||||
curPartLen--
|
||||
} else {
|
||||
curPartLen += lastSize - 1
|
||||
break
|
||||
if e.readErr == nil && curPartEnd == len(e.readBuf) {
|
||||
for i := curPartLen - 1; i >= 0; i-- {
|
||||
last, lastSize := utf8.DecodeRune(curPart[i:])
|
||||
if last == utf8.RuneError && lastSize == 1 {
|
||||
curPartLen--
|
||||
} else {
|
||||
curPartLen += lastSize - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if curPartLen == 0 {
|
||||
if !utf8.FullRune(curPart) && len(curPart) <= cap(e.readBuf)*3/4 {
|
||||
break // keep a rune cut by the read boundary for the next read
|
||||
}
|
||||
// actually it's impossible that the part doesn't contain any valid rune,
|
||||
// the only case is that the cap(readBuf) is too small, or the origin contain indeed doesn't contain any valid rune
|
||||
// the only case is that the cap(readBuf) is too small, or the origin content indeed doesn't contain any valid rune
|
||||
// * try to leave the last 4 bytes (possible longest utf-8 encoding) to next round
|
||||
// * at least consume 1 byte to avoid infinite loop
|
||||
curPartLen = max(len(curPart)-utf8.UTFMax, 1)
|
||||
|
||||
@@ -6,6 +6,7 @@ package charset
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/iotest"
|
||||
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -160,6 +161,11 @@ func TestEscapeControlReader(t *testing.T) {
|
||||
assert.Equal(t, tt.result, outStr)
|
||||
})
|
||||
}
|
||||
|
||||
output := &htmlutil.HTMLBuilder{}
|
||||
_, err := EscapeControlReader(iotest.OneByteReader(strings.NewReader("\xef\xbb\xbftest")), output, &translation.MockLocale{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "\xef\xbb\xbftest", output.String())
|
||||
}
|
||||
|
||||
func TestTrackHtmlTag(t *testing.T) {
|
||||
@@ -234,4 +240,11 @@ func TestHTMLChunkReader(t *testing.T) {
|
||||
testReadChunks(t, 5, input, []textPart{{"<Aé", true}, {"啊", true}, {"🌞>", true}, {"Aé", false}, {"啊", false}, {"🌞", false}})
|
||||
testReadChunks(t, 6, input, []textPart{{"<Aé", true}, {"啊", true}, {"🌞>", true}, {"A", false}, {"é啊", false}, {"🌞", false}})
|
||||
testReadChunks(t, 7, input, []textPart{{"<Aé啊", true}, {"🌞>", true}, {"A", false}, {"é啊", false}, {"🌞", false}})
|
||||
|
||||
for chunkSize := 6; chunkSize < 5+len(rune4); chunkSize++ {
|
||||
testReadChunks(t, chunkSize, "<abc>"+rune4, []textPart{{"<abc>", true}, {rune4, false}})
|
||||
}
|
||||
testReadChunks(t, 6, "<abc>"+rune3[:2], []textPart{{"<abc>", true}, {rune3[:2], false}})
|
||||
testReadChunks(t, 7, "<abc>"+rune3[:2]+"<b>", []textPart{{"<abc>", true}, {rune3[:2], false}, {"<b>", true}})
|
||||
testReadChunks(t, 1, rune2, []textPart{{rune2[:1], false}, {rune2[1:], false}})
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ func syncGitConfig(ctx context.Context) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Gitea used to write "90", which git reads as a date in 1990
|
||||
if err := configUnsetAll(ctx, "gc.reflogExpire", "90"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply user's git config options last so they take precedence over builtin defaults
|
||||
for k, v := range setting.GitConfig.Options {
|
||||
if err = configSet(ctx, strings.ToLower(k), v); err != nil {
|
||||
@@ -162,7 +167,7 @@ func configSet(ctx context.Context, key, value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, _, err = gitcmd.NewCommand("config", "--global").
|
||||
if _, _, err = gitcmd.NewCommand("config", "--global", "--replace-all").
|
||||
AddDynamicArguments(key, value).
|
||||
RunStdString(ctx); err != nil {
|
||||
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
|
||||
@@ -190,7 +195,7 @@ func configSetNonExist(ctx context.Context, key, value string) error {
|
||||
}
|
||||
|
||||
func configAddNonExist(ctx context.Context, key, value string) error {
|
||||
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
|
||||
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx)
|
||||
if err == nil {
|
||||
// already exist
|
||||
return nil
|
||||
@@ -207,18 +212,9 @@ func configAddNonExist(ctx context.Context, key, value string) error {
|
||||
}
|
||||
|
||||
func configUnsetAll(ctx context.Context, key, value string) error {
|
||||
_, _, err := gitcmd.NewCommand("config", "--global", "--get").AddDynamicArguments(key).RunStdString(ctx)
|
||||
if err == nil {
|
||||
// exist, need to remove
|
||||
_, _, err = gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, regexp.QuoteMeta(value)).RunStdString(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
_, _, err := gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx)
|
||||
if err != nil && !gitcmd.IsErrorExitCode(err, 5) { // 5: no such key or no matching value
|
||||
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
|
||||
}
|
||||
if gitcmd.IsErrorExitCode(err, 1) {
|
||||
// not exist
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ func TestGitConfig(t *testing.T) {
|
||||
assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed"))
|
||||
assert.False(t, gitConfigContains("key-a = val-a-changed"))
|
||||
|
||||
assert.NoError(t, configAddNonExist(ctx, "test.key-a", "val-a2"))
|
||||
assert.NoError(t, configSet(ctx, "test.key-a", "val-a-changed"))
|
||||
assert.NoError(t, configUnsetAll(ctx, "test.key-a", "val-a"))
|
||||
assert.True(t, gitConfigContains("key-a = val-a-changed"))
|
||||
|
||||
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-b"))
|
||||
@@ -41,6 +43,8 @@ func TestGitConfig(t *testing.T) {
|
||||
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-2b"))
|
||||
assert.True(t, gitConfigContains("key-b = val-b"))
|
||||
assert.True(t, gitConfigContains("key-b = val-2b"))
|
||||
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val"))
|
||||
assert.True(t, gitConfigContains("key-b = val\n"))
|
||||
|
||||
assert.NoError(t, configUnsetAll(ctx, "test.key-b", "val-b"))
|
||||
assert.False(t, gitConfigContains("key-b = val-b"))
|
||||
@@ -60,8 +64,10 @@ func TestSyncGitConfig(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.GitConfig)()
|
||||
|
||||
assert.Empty(t, setting.GitConfig.Options)
|
||||
assert.NoError(t, configSet(t.Context(), "gc.reflogExpire", "90"))
|
||||
assert.NoError(t, syncGitConfig(t.Context()))
|
||||
assert.True(t, gitConfigContains("commitGraph = true")) // builtin default config
|
||||
assert.False(t, gitConfigContains("reflogExpire"))
|
||||
|
||||
setting.GitConfig.Options["sync-test.cfg-key-a"] = "CfgValA"
|
||||
setting.GitConfig.Options["core.commitgraph"] = "false"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user