mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-02 13:44:16 +09:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f5a3e13e5 | ||
|
|
c50d5fc20d | ||
|
|
dfdb0438ad | ||
|
|
7868ade8f7 | ||
|
|
67904da6a5 | ||
|
|
6f5d66f6dd | ||
|
|
a1c56dd96a | ||
|
|
5a3f06430d | ||
|
|
6c8eaaf9e7 | ||
|
|
c9e1805cc9 | ||
|
|
5ba69f9af4 | ||
|
|
e395b4d9dc | ||
|
|
308dd93484 | ||
|
|
3162dc4cbf | ||
|
|
51736a9706 | ||
|
|
40079c7c09 | ||
|
|
2b11ba861f | ||
|
|
9ad9f99060 | ||
|
|
0964106988 | ||
|
|
8cf0f9798f | ||
|
|
5d737d660b | ||
|
|
5be73fa93a | ||
|
|
3ed5886066 | ||
|
|
12b5af068f | ||
|
|
8e83bb0e61 | ||
|
|
2042caf945 | ||
|
|
79189a959f | ||
|
|
1b8a7c4d85 | ||
|
|
dbe80ac05c | ||
|
|
4b669ca620 | ||
|
|
9d335e8783 | ||
|
|
85a85878c7 | ||
|
|
ef2ceff6cd | ||
|
|
35a6f3d4e0 | ||
|
|
cf3d325e45 | ||
|
|
b140311d54 | ||
|
|
2b0aa6cfd2 | ||
|
|
9d1d5ceddb | ||
|
|
9141a6386f | ||
|
|
a814cf7567 | ||
|
|
2fd12d5f76 | ||
|
|
640b528580 | ||
|
|
3e5256b6ca | ||
|
|
3996c2c520 | ||
|
|
716295015b | ||
|
|
f50a5d0dab | ||
|
|
bc3e228e78 | ||
|
|
193c676946 | ||
|
|
977c9181ad | ||
|
|
197e761471 | ||
|
|
01db50f0bf | ||
|
|
4b743d2e8c | ||
|
|
31653bdb34 | ||
|
|
7365946851 | ||
|
|
254e18107e | ||
|
|
290568a07b | ||
|
|
b758396619 | ||
|
|
9bc4ce88ee | ||
|
|
9bb9a51623 | ||
|
|
5838cc8172 | ||
|
|
829b49c1ae | ||
|
|
58031cdc97 | ||
|
|
1eee73e9bd | ||
|
|
dc30660291 | ||
|
|
6bc0e8cd57 | ||
|
|
cfcbdf41e2 | ||
|
|
6195cda58f | ||
|
|
5251cd572f | ||
|
|
ce8912c8ff | ||
|
|
0e2a188d8e | ||
|
|
95d6b572fe | ||
|
|
c4e9d96162 | ||
|
|
96cb6cd16f | ||
|
|
02b18a0c8e | ||
|
|
1ffe46d2a4 | ||
|
|
26f537dea6 | ||
|
|
74e7e813c3 | ||
|
|
3f0aa7a01e | ||
|
|
7e1e32b42e | ||
|
|
63669003eb | ||
|
|
f5fd936cbb | ||
|
|
d87bbd3dd4 | ||
|
|
7a03ec3a4c | ||
|
|
0132cb82c2 | ||
|
|
33aaf0cfa3 | ||
|
|
6064554b8d | ||
|
|
d29364a4f6 | ||
|
|
998a06627e | ||
|
|
250d18d5f6 | ||
|
|
c26daf578f | ||
|
|
5371e788fe | ||
|
|
0001e58e6b | ||
|
|
79c4237cb2 | ||
|
|
20f73b2230 | ||
|
|
175bc89bf9 | ||
|
|
793e77b073 | ||
|
|
5018ae1b29 | ||
|
|
c60b13b2dd | ||
|
|
27bd022b34 |
No files matched your search
@@ -19,7 +19,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
- uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||||
gitea_fork: giteabot/gitea
|
gitea_fork: giteabot/gitea
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ on:
|
|||||||
checks:
|
checks:
|
||||||
description: Comma-separated list of non-backport checks to run
|
description: Comma-separated list of non-backport checks to run
|
||||||
required: false
|
required: false
|
||||||
default: labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
|
default: backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
statuses: write
|
statuses: write
|
||||||
steps:
|
steps:
|
||||||
- uses: go-gitea/giteabot@f48c6a15e0d384f037aea19cc05ff5e9551096b9 # v1.1.0
|
- uses: go-gitea/giteabot@36ea99f821fefa3e8fb26aeac6211735664cd9c0 # v1.3.1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
github_token: ${{ secrets.GITEABOT_TOKEN }}
|
||||||
checks: ${{ github.event.inputs.checks || 'labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
checks: ${{ github.event.inputs.checks || 'backport_branches,labels,merge_queue,lock,feedback,last_call,milestones,lgtm,translation_comment,pr_actions' }}
|
||||||
@@ -13,7 +13,9 @@ permissions: {}
|
|||||||
jobs:
|
jobs:
|
||||||
tag:
|
tag:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: release-signing
|
environment:
|
||||||
|
name: release-signing
|
||||||
|
deployment: false
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -37,8 +37,15 @@ jobs:
|
|||||||
# retry snapcraft uploads which can be flaky
|
# retry snapcraft uploads which can be flaky
|
||||||
- name: Publish snap
|
- name: Publish snap
|
||||||
run: |
|
run: |
|
||||||
|
# part-gitea-pull.sh decides what is built, see the comments there:
|
||||||
|
# * grade devel (main or a prerelease tag) goes to latest/edge
|
||||||
|
# * grade stable (a new release tag) goes to latest/stable, and to latest/candidate which marks it as released
|
||||||
|
channel=latest/edge
|
||||||
|
if unsquashfs -cat "$SNAP" meta/snap.yaml | grep -qx 'grade: stable'; then
|
||||||
|
channel=latest/stable,latest/candidate
|
||||||
|
fi
|
||||||
for attempt in 1 2 3 4 5; do
|
for attempt in 1 2 3 4 5; do
|
||||||
snapcraft upload "$SNAP" --release latest/edge && exit 0
|
snapcraft upload "$SNAP" --release "$channel" && exit 0
|
||||||
echo "::warning::snap upload attempt $attempt failed, retrying in 15s"
|
echo "::warning::snap upload attempt $attempt failed, retrying in 15s"
|
||||||
sleep 15
|
sleep 15
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ jobs:
|
|||||||
run: |
|
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
|
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"
|
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
|
- uses: orhun/git-cliff-action@a9a95522b26fe6403f7bb24031f21fb573d0f5ff # v4.9.1
|
||||||
with:
|
with:
|
||||||
args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }}
|
args: --tag ${{ github.ref_name }} ${{ steps.range.outputs.range }}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ SHASUM ?= shasum -a 256
|
|||||||
|
|
||||||
AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go
|
AIR_PACKAGE ?= github.com/air-verse/air@v1.67.4 # renovate: datasource=go
|
||||||
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go
|
EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v4/cmd/editorconfig-checker@v4.0.2 # renovate: datasource=go
|
||||||
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 # renovate: datasource=go
|
GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 # renovate: datasource=go
|
||||||
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go
|
GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.17 # renovate: datasource=go
|
||||||
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
|
MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go
|
||||||
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6 # renovate: datasource=go
|
SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.36.6 # renovate: datasource=go
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
users, err := user_model.GetAllUsers(ctx)
|
users, err := user_model.GetAllUsersForAdminListing(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -41,17 +41,17 @@ func runListUsers(ctx context.Context, c *cli.Command) error {
|
|||||||
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 5, 0, 1, ' ', 0)
|
||||||
|
|
||||||
if c.IsSet("admin") {
|
if c.IsSet("admin") {
|
||||||
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\n")
|
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tType\n")
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if u.IsAdmin {
|
if u.IsAdmin {
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\n", u.ID, u.Name, u.Email, u.IsActive)
|
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%s\n", u.ID, u.Name, u.Email, u.IsActive, u.Type.DisplayName())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
twofa := user_model.UserList(users).GetTwoFaStatus(ctx)
|
twofa := user_model.UserList(users).GetTwoFaStatus(ctx)
|
||||||
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\n")
|
fmt.Fprintf(w, "ID\tUsername\tEmail\tIsActive\tIsAdmin\t2FA\tType\n")
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID])
|
fmt.Fprintf(w, "%d\t%s\t%s\t%t\t%t\t%t\t%s\n", u.ID, u.Name, u.Email, u.IsActive, u.IsAdmin, twofa[u.ID], u.Type.DisplayName())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -638,7 +638,8 @@ ENABLED = true
|
|||||||
;; * https://github.com/git-ecosystem/git-credential-manager
|
;; * https://github.com/git-ecosystem/git-credential-manager
|
||||||
;; * https://gitea.com/gitea/tea
|
;; * https://gitea.com/gitea/tea
|
||||||
;; * Gitea App (the official Gitea mobile app)
|
;; * Gitea App (the official Gitea mobile app)
|
||||||
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app
|
;; * https://gitea.com/gitea/gitea-vscode
|
||||||
|
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app, gitea-vscode
|
||||||
;;
|
;;
|
||||||
;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes.
|
;; By default, OAuth2 applications can only use "http" and "https" as their redirect URI schemes.
|
||||||
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
|
;; If you need to use other schemes (e.g. for desktop applications), you can specify them here as a comma-separated list.
|
||||||
@@ -810,7 +811,7 @@ LEVEL = Info
|
|||||||
;[git.config]
|
;[git.config]
|
||||||
;diff.algorithm = histogram
|
;diff.algorithm = histogram
|
||||||
;core.logAllRefUpdates = true
|
;core.logAllRefUpdates = true
|
||||||
;gc.reflogExpire = 90
|
;maintenance.strategy = gc
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -1098,8 +1099,9 @@ LEVEL = Info
|
|||||||
;; Force ssh:// clone url instead of scp-style uri when default SSH port is used
|
;; Force ssh:// clone url instead of scp-style uri when default SSH port is used
|
||||||
;USE_COMPAT_SSH_URI = false
|
;USE_COMPAT_SSH_URI = false
|
||||||
;;
|
;;
|
||||||
;; Value for the "go get" request returns the repository url as https or ssh, default is https
|
;; Scheme of the returned URL for the "go get" response.
|
||||||
;GO_GET_CLONE_URL_PROTOCOL = https
|
;; Default is "https" if DISABLE_HTTP_GIT=false or SSH is disabled, otherwise "ssh".
|
||||||
|
;GO_GET_CLONE_URL_PROTOCOL =
|
||||||
;;
|
;;
|
||||||
;; Close issues as long as a commit on any branch marks it as fixed
|
;; Close issues as long as a commit on any branch marks it as fixed
|
||||||
;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false
|
;DEFAULT_CLOSE_ISSUES_VIA_COMMITS_IN_ANY_BRANCH = false
|
||||||
@@ -3082,7 +3084,7 @@ LEVEL = Info
|
|||||||
;LOG_RETENTION_DAYS = 365
|
;LOG_RETENTION_DAYS = 365
|
||||||
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
|
;; Days to keep artifacts. Old artifacts will be deleted after this period. 0 means keep forever.
|
||||||
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
|
;; Changes only apply to newly uploaded artifacts, existing ones keep the expiry stored when they were uploaded.
|
||||||
;; Artifacts could have their own retention periods by setting the `retention-days` option in `actions/upload-artifact` step.
|
;; Workflows can request a shorter retention period, down to 1 day.
|
||||||
;ARTIFACT_RETENTION_DAYS = 90
|
;ARTIFACT_RETENTION_DAYS = 90
|
||||||
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
|
;; Max artifact size in bytes that can be browsed or previewed in the web UI. Set to 0 to disable artifact previews, or -1 for no limit.
|
||||||
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760
|
;ARTIFACT_PREVIEW_MAX_SIZE = 10485760
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ module gitea.dev
|
|||||||
|
|
||||||
go 1.27
|
go 1.27
|
||||||
|
|
||||||
toolchain go1.27.1
|
toolchain go1.27.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
connectrpc.com/connect v1.21.0
|
connectrpc.com/connect v1.21.0
|
||||||
@@ -13,7 +13,7 @@ require (
|
|||||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
||||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
||||||
gitea.dev/actionslib v1.2.1
|
gitea.dev/actionslib v1.2.1
|
||||||
gitea.dev/sdk v1.2.0
|
gitea.dev/sdk v1.3.0
|
||||||
github.com/42wim/httpsig v1.2.4
|
github.com/42wim/httpsig v1.2.4
|
||||||
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
|
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432
|
||||||
github.com/Azure/go-ntlmssp v0.1.1
|
github.com/Azure/go-ntlmssp v0.1.1
|
||||||
@@ -94,15 +94,15 @@ require (
|
|||||||
github.com/yuin/goldmark v1.8.6
|
github.com/yuin/goldmark v1.8.6
|
||||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||||
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
|
gitlab.com/gitlab-org/api/client-go/v3 v3.13.0
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.58.0
|
||||||
golang.org/x/image v0.46.0
|
golang.org/x/image v0.46.0
|
||||||
golang.org/x/mod v0.41.0
|
golang.org/x/mod v0.41.0
|
||||||
golang.org/x/net v0.59.0
|
golang.org/x/net v0.62.0
|
||||||
golang.org/x/oauth2 v0.37.0
|
golang.org/x/oauth2 v0.37.0
|
||||||
golang.org/x/sync v0.23.0
|
golang.org/x/sync v0.24.0
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/text v0.42.0
|
golang.org/x/text v0.43.0
|
||||||
google.golang.org/grpc v1.84.0
|
google.golang.org/grpc v1.84.0
|
||||||
google.golang.org/protobuf v1.36.12
|
google.golang.org/protobuf v1.36.12
|
||||||
gopkg.in/ini.v1 v1.67.3
|
gopkg.in/ini.v1 v1.67.3
|
||||||
@@ -252,7 +252,7 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.49.0 // indirect
|
golang.org/x/tools v0.51.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
modernc.org/libc v1.75.7 // indirect
|
modernc.org/libc v1.75.7 // indirect
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGq
|
|||||||
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
|
||||||
gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
|
gitea.dev/actionslib v1.2.1 h1:GL//K/0zIZV6h1OOksv1awvFVg0rg7fug2xWcG7mkG0=
|
||||||
gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
|
gitea.dev/actionslib v1.2.1/go.mod h1:1+gqOKGSEPn2IFHgY8S3GC5Ld+Hn8jF3OxiFHQ/fpx4=
|
||||||
gitea.dev/sdk v1.2.0 h1:avRtJl/nKCGispgSalo9czoZM9Rto1awnE0caNAoXGo=
|
gitea.dev/sdk v1.3.0 h1:4U95+Ou6WHCQiRPDEyAzvl1Q7cxg5RUTWkqmOXr5w4I=
|
||||||
gitea.dev/sdk v1.2.0/go.mod h1:rfh5oNdIK24cbCREwIn1tqWKQW+IICXFGWJyebuOAOE=
|
gitea.dev/sdk v1.3.0/go.mod h1:85EBqSAqUn1KlFCk40y3sTjWjBsB/KouL3slGXMA/2o=
|
||||||
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
|
||||||
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
|
||||||
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 h1:3Fcz1QzlS7Jv4FT2KI3cHNSZL+KPN3dXxurn9f3YL/Y=
|
github.com/42wim/sshsig v0.0.0-20260317195500-b9f38cf0d432 h1:3Fcz1QzlS7Jv4FT2KI3cHNSZL+KPN3dXxurn9f3YL/Y=
|
||||||
@@ -672,8 +672,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
|||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525 h1:FtzHH3s777Nx50YcPAcodAPm1mNakc228nMYbr3f+90=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
|
||||||
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -686,8 +686,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||||
@@ -714,8 +714,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -728,8 +728,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -756,8 +756,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -767,8 +767,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -779,8 +779,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -790,8 +790,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
|||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
@@ -93,7 +93,12 @@ const artifactKeepForever timeutil.TimeStamp = 0
|
|||||||
|
|
||||||
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
|
func artifactExpiry(requested optional.Option[timeutil.TimeStamp]) timeutil.TimeStamp {
|
||||||
if requested.Has() {
|
if requested.Has() {
|
||||||
return max(requested.Value(), artifactKeepForever+1)
|
now := timeutil.TimeStampNow()
|
||||||
|
expiry := max(requested.Value(), now.Add(timeutil.Day)) // v4 clients use the runner's clock
|
||||||
|
if setting.Actions.ArtifactRetentionDays > 0 {
|
||||||
|
expiry = min(expiry, now.Add(timeutil.Day*setting.Actions.ArtifactRetentionDays))
|
||||||
|
}
|
||||||
|
return expiry
|
||||||
}
|
}
|
||||||
if setting.Actions.ArtifactRetentionDays <= 0 {
|
if setting.Actions.ArtifactRetentionDays <= 0 {
|
||||||
return artifactKeepForever
|
return artifactKeepForever
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ package actions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dev/modules/optional"
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
"gitea.dev/modules/timeutil"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -25,3 +31,21 @@ func TestKeepLatestAttemptArtifacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
assert.Equal(t, []int64{1, 4, 5}, ids)
|
assert.Equal(t, []int64{1, 4, 5}, ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestArtifactExpiry(t *testing.T) {
|
||||||
|
defer timeutil.MockSet(time.Unix(1_000_000_000, 0))()
|
||||||
|
now := timeutil.TimeStampNow()
|
||||||
|
days := func(n int64) timeutil.TimeStamp { return now.Add(n * timeutil.Day) }
|
||||||
|
requested := optional.Some[timeutil.TimeStamp]
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.Actions.ArtifactRetentionDays, 90)()
|
||||||
|
assert.Equal(t, days(90), artifactExpiry(optional.None[timeutil.TimeStamp]()))
|
||||||
|
assert.Equal(t, days(5), artifactExpiry(requested(days(5))))
|
||||||
|
assert.Equal(t, days(1), artifactExpiry(requested(days(-2))), "runner clock behind must not expire the artifact at once")
|
||||||
|
assert.Equal(t, days(90), artifactExpiry(requested(days(1000))), "runner must not exceed the instance retention")
|
||||||
|
|
||||||
|
setting.Actions.ArtifactRetentionDays = 0
|
||||||
|
assert.Equal(t, artifactKeepForever, artifactExpiry(optional.None[timeutil.TimeStamp]()))
|
||||||
|
assert.Equal(t, days(1000), artifactExpiry(requested(days(1000))))
|
||||||
|
assert.Equal(t, days(1), artifactExpiry(requested(now)))
|
||||||
|
}
|
||||||
@@ -34,8 +34,8 @@ type ActionRun struct {
|
|||||||
RepoID int64 `xorm:"unique(repo_index) index(repo_status)"`
|
RepoID int64 `xorm:"unique(repo_index) index(repo_status)"`
|
||||||
Repo *repo_model.Repository `xorm:"-"`
|
Repo *repo_model.Repository `xorm:"-"`
|
||||||
OwnerID int64 `xorm:"index"`
|
OwnerID int64 `xorm:"index"`
|
||||||
WorkflowID string `xorm:"index"` // the name of workflow file
|
WorkflowID string `xorm:"index"` // the name of workflow file
|
||||||
Index int64 `xorm:"index unique(repo_index)"` // a unique number for each run of a repository
|
Index int64 `xorm:"unique(repo_index)"` // a unique number for each run of a repository
|
||||||
TriggerUserID int64 `xorm:"index"`
|
TriggerUserID int64 `xorm:"index"`
|
||||||
TriggerUser *user_model.User `xorm:"-"`
|
TriggerUser *user_model.User `xorm:"-"`
|
||||||
ScheduleID int64
|
ScheduleID int64
|
||||||
|
|||||||
+22
-22
@@ -25,7 +25,6 @@ import (
|
|||||||
|
|
||||||
// MaxJobNumPerRun is the maximum number of jobs in a single run.
|
// MaxJobNumPerRun is the maximum number of jobs in a single run.
|
||||||
// https://docs.github.com/en/actions/reference/limits#existing-system-limits
|
// https://docs.github.com/en/actions/reference/limits#existing-system-limits
|
||||||
// TODO: check this limit when creating jobs
|
|
||||||
const MaxJobNumPerRun = 256
|
const MaxJobNumPerRun = 256
|
||||||
|
|
||||||
// ActionRunJob represents a job of a run
|
// ActionRunJob represents a job of a run
|
||||||
@@ -100,7 +99,7 @@ type ActionRunJob struct {
|
|||||||
|
|
||||||
// IsReusableCaller marks this job as a reusable workflow caller.
|
// IsReusableCaller marks this job as a reusable workflow caller.
|
||||||
// Caller jobs do not run on a runner; their status is derived from their child jobs.
|
// Caller jobs do not run on a runner; their status is derived from their child jobs.
|
||||||
IsReusableCaller bool `xorm:"index NOT NULL DEFAULT FALSE"`
|
IsReusableCaller bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||||
// IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete.
|
// IsExpanded reports whether this job's lazy expansion (children-row insertion) is complete.
|
||||||
// For a reusable workflow caller, true means children rows exist and CallPayload is populated.
|
// For a reusable workflow caller, true means children rows exist and CallPayload is populated.
|
||||||
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
|
IsExpanded bool `xorm:"NOT NULL DEFAULT FALSE"`
|
||||||
@@ -440,14 +439,6 @@ func CollectAllDescendantJobs(parent *ActionRunJob, allJobs []*ActionRunJob) []*
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasWaitingJobsToPick reports whether any waiting, unclaimed, non-reusable job
|
|
||||||
// remains in the repo, i.e. work that an idle runner could still pick up.
|
|
||||||
func hasWaitingJobsToPick(ctx context.Context, repoID int64) (bool, error) {
|
|
||||||
return db.GetEngine(ctx).
|
|
||||||
Where("repo_id = ? AND task_id = ? AND status = ? AND is_reusable_caller = ?", repoID, 0, StatusWaiting, false).
|
|
||||||
Exist(&ActionRunJob{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, cols ...string) (int64, error) {
|
||||||
e := db.GetEngine(ctx)
|
e := db.GetEngine(ctx)
|
||||||
|
|
||||||
@@ -498,7 +489,7 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
|||||||
// Only bump when waiting work actually remains for this repo, otherwise
|
// Only bump when waiting work actually remains for this repo, otherwise
|
||||||
// every job completion would needlessly bump the global version and wake
|
// every job completion would needlessly bump the global version and wake
|
||||||
// every idle runner instance-wide for nothing.
|
// every idle runner instance-wide for nothing.
|
||||||
hasWaiting, err := hasWaitingJobsToPick(ctx, job.RepoID)
|
hasWaiting, err := JobQueueOptions{RepoID: job.RepoID, Status: StatusWaiting}.session(ctx).Exist(new(ActionRunJob))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -853,25 +844,34 @@ func cancelReusableCaller(ctx context.Context, caller *ActionRunJob, force bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
|
// Cancel descendants deepest-first, then the caller: a caller's status is aggregated from its children,
|
||||||
// so each child must reach its final state before its parent caller is re-aggregated.
|
// so each child must be cancelled before its parent caller is re-read.
|
||||||
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
|
// A child's ID always exceeds its parent's, so descending ID is a valid deepest-first order.
|
||||||
descendants := CollectAllDescendantJobs(caller, attemptJobs)
|
descendants := CollectAllDescendantJobs(caller, attemptJobs)
|
||||||
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
slices.SortFunc(descendants, func(a, b *ActionRunJob) int { return cmp.Compare(b.ID, a.ID) })
|
||||||
|
callersWithChildren := make(container.Set[int64])
|
||||||
|
for _, d := range descendants {
|
||||||
|
callersWithChildren.Add(d.ParentJobID)
|
||||||
|
}
|
||||||
|
|
||||||
for _, c := range descendants {
|
for _, job := range append(descendants, caller) {
|
||||||
cancelled, err := cancelOneJob(ctx, c, force)
|
if !callersWithChildren.Contains(job.ID) {
|
||||||
|
cancelled, err := cancelOneJob(ctx, job, force)
|
||||||
|
if err != nil {
|
||||||
|
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 {
|
if err != nil {
|
||||||
return cancelledJobs, err
|
return cancelledJobs, err
|
||||||
}
|
}
|
||||||
if cancelled != nil {
|
if reloaded.Status != job.Status {
|
||||||
cancelledJobs = append(cancelledJobs, cancelled)
|
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
|
return cancelledJobs, nil
|
||||||
}
|
}
|
||||||
@@ -27,13 +27,12 @@ func (opts JobQueueOptions) session(ctx context.Context) *xorm.Session {
|
|||||||
sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
|
sess = sess.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID})
|
||||||
}
|
}
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID > 0 {
|
||||||
sess = sess.Join("INNER", "repository", "repository.id = `action_run_job`.repo_id AND repository.owner_id = ?", opts.OwnerID)
|
sess = sess.And(builder.In("`action_run_job`.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"owner_id": opts.OwnerID})))
|
||||||
}
|
}
|
||||||
return sess.And(opts.statusCond())
|
return sess.And(opts.statusCond())
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// keep in sync with CreateTaskForRunner
|
|
||||||
queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0}
|
queuedJobsCond = builder.Eq{"`action_run_job`.status": StatusWaiting, "`action_run_job`.task_id": 0}
|
||||||
// a cancelling job still occupies its runner
|
// a cancelling job still occupies its runner
|
||||||
runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling)
|
runningJobsCond = builder.In("`action_run_job`.status", StatusRunning, StatusCancelling)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
@@ -397,3 +398,72 @@ func TestForceCancelJobs(t *testing.T) {
|
|||||||
assert.Equal(t, StatusCancelled, callerAfter.Status)
|
assert.Equal(t, StatusCancelled, callerAfter.Status)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCancelJobs_CallerWaitsForCancellingChild(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
run := &ActionRun{
|
||||||
|
Title: "caller-cancelling-child",
|
||||||
|
RepoID: 4,
|
||||||
|
Index: 9811,
|
||||||
|
OwnerID: 1,
|
||||||
|
WorkflowID: "test.yaml",
|
||||||
|
TriggerUserID: 1,
|
||||||
|
Ref: "refs/heads/master",
|
||||||
|
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||||
|
Event: "push",
|
||||||
|
TriggerEvent: "push",
|
||||||
|
EventPayload: "{}",
|
||||||
|
Status: StatusRunning,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Insert(ctx, run))
|
||||||
|
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusRunning}
|
||||||
|
require.NoError(t, db.Insert(ctx, attempt))
|
||||||
|
run.LatestAttemptID = attempt.ID
|
||||||
|
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||||
|
|
||||||
|
newJob := func(name string, parentID int64, isCaller bool) *ActionRunJob {
|
||||||
|
job := &ActionRunJob{
|
||||||
|
RunID: run.ID,
|
||||||
|
RunAttemptID: attempt.ID,
|
||||||
|
RepoID: run.RepoID,
|
||||||
|
OwnerID: run.OwnerID,
|
||||||
|
CommitSHA: run.CommitSHA,
|
||||||
|
Name: name,
|
||||||
|
JobID: name,
|
||||||
|
Attempt: 1,
|
||||||
|
Status: StatusRunning,
|
||||||
|
ParentJobID: parentID,
|
||||||
|
IsReusableCaller: isCaller,
|
||||||
|
IsExpanded: isCaller,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.Insert(ctx, job))
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
outer := newJob("outer", 0, true)
|
||||||
|
inner := newJob("inner", outer.ID, true)
|
||||||
|
child := newJob("child", inner.ID, false)
|
||||||
|
|
||||||
|
runner := &ActionRunner{UUID: "caller-cancelling-child", Name: "caller-cancelling-child", HasCancellingSupport: true}
|
||||||
|
require.NoError(t, db.Insert(ctx, runner))
|
||||||
|
task := &ActionTask{JobID: child.ID, Attempt: 1, RunnerID: runner.ID, Status: StatusRunning, Started: timeutil.TimeStampNow(), RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA}
|
||||||
|
require.NoError(t, db.Insert(ctx, task))
|
||||||
|
child.TaskID = task.ID
|
||||||
|
_, err := UpdateRunJob(ctx, child, nil, "task_id")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cancelled, err := CancelJobs(ctx, []*ActionRunJob{outer}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, cancelled, 3)
|
||||||
|
for _, job := range []*ActionRunJob{outer, inner, child} {
|
||||||
|
assert.Equal(t, StatusCancelling, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = UpdateTaskByState(ctx, runner.ID, &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_CANCELLED})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, job := range []*ActionRunJob{outer, inner, child} {
|
||||||
|
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status, job.Name)
|
||||||
|
}
|
||||||
|
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID}).Status)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/git"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
webhook_module "gitea.dev/modules/webhook"
|
webhook_module "gitea.dev/modules/webhook"
|
||||||
@@ -110,7 +111,7 @@ func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) ([
|
|||||||
jobs, err := CancelPreviousJobs(
|
jobs, err := CancelPreviousJobs(
|
||||||
ctx,
|
ctx,
|
||||||
repo.ID,
|
repo.ID,
|
||||||
repo.DefaultBranch,
|
git.RefNameFromBranch(repo.DefaultBranch).String(),
|
||||||
"",
|
"",
|
||||||
webhook_module.HookEventSchedule,
|
webhook_module.HookEventSchedule,
|
||||||
)
|
)
|
||||||
|
|||||||
+30
-34
@@ -236,13 +236,13 @@ func GetRunningTaskByToken(ctx context.Context, token string) (*ActionTask, erro
|
|||||||
|
|
||||||
func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
func makeTaskStepDisplayName(step *jobparser.Step, limit int) (name string) {
|
||||||
if step.Name != "" {
|
if step.Name != "" {
|
||||||
name = string(step.Name) // the step has an explicit name
|
name = step.Name // the step has an explicit name
|
||||||
} else {
|
} else {
|
||||||
// for unnamed step, its "String()" method tries to get a display name by its "name", "uses",
|
// for unnamed step, its "String()" method tries to get a display name by its "name", "uses",
|
||||||
// "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display
|
// "run" or "id" (last fallback), we add the "Run " prefix for unnamed steps for better display
|
||||||
// for multi-line "run" scripts, only use the first line to match GitHub's behavior
|
// for multi-line "run" scripts, only use the first line to match GitHub's behavior
|
||||||
// https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58
|
// https://github.com/actions/runner/blob/66800900843747f37591b077091dd2c8cf2c1796/src/Runner.Worker/Handlers/ScriptHandler.cs#L45-L58
|
||||||
runStr, _, _ := strings.Cut(strings.TrimSpace(string(step.Run)), "\n")
|
runStr, _, _ := strings.Cut(strings.TrimSpace(step.Run), "\n")
|
||||||
name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String())
|
name = "Run " + util.IfZero(strings.TrimSpace(runStr), step.String())
|
||||||
}
|
}
|
||||||
return util.EllipsisDisplayString(name, limit) // database column has a length limit
|
return util.EllipsisDisplayString(name, limit) // database column has a length limit
|
||||||
@@ -261,21 +261,10 @@ var pickTaskBatchSize = 100
|
|||||||
// atomically claims it. It iterates through all matching jobs so that a
|
// atomically claims it. It iterates through all matching jobs so that a
|
||||||
// concurrent claim by another runner (which would lose the optimistic lock on
|
// concurrent claim by another runner (which would lose the optimistic lock on
|
||||||
// job #1) does not leave the remaining jobs permanently unassigned.
|
// job #1) does not leave the remaining jobs permanently unassigned.
|
||||||
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask, bool, error) {
|
func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (task *ActionTask, cancelledJobs []*ActionRunJob, err error) {
|
||||||
if db.InTransaction(ctx) {
|
if db.InTransaction(ctx) {
|
||||||
return nil, false, errors.New("CreateTaskForRunner must not be called within a database transaction")
|
return nil, nil, errors.New("CreateTaskForRunner must not be called within a database transaction")
|
||||||
}
|
}
|
||||||
e := db.GetEngine(ctx)
|
|
||||||
|
|
||||||
jobCond := builder.NewCond()
|
|
||||||
if runner.RepoID != 0 {
|
|
||||||
jobCond = builder.Eq{"repo_id": runner.RepoID}
|
|
||||||
} else if runner.OwnerID != 0 {
|
|
||||||
jobCond = builder.In("repo_id", builder.Select("`repository`.id").From("repository").
|
|
||||||
Join("INNER", "repo_unit", "`repository`.id = `repo_unit`.repo_id").
|
|
||||||
Where(builder.Eq{"`repository`.owner_id": runner.OwnerID, "`repo_unit`.type": unit.TypeActions}))
|
|
||||||
}
|
|
||||||
baseCond := builder.Eq{"task_id": 0, "status": StatusWaiting, "is_reusable_caller": false}.And(jobCond)
|
|
||||||
|
|
||||||
// TODO: a more efficient way to filter labels
|
// TODO: a more efficient way to filter labels
|
||||||
log.Trace("runner labels: %v", runner.AgentLabels)
|
log.Trace("runner labels: %v", runner.AgentLabels)
|
||||||
@@ -286,17 +275,17 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
|||||||
var cursorUpdated timeutil.TimeStamp
|
var cursorUpdated timeutil.TimeStamp
|
||||||
var cursorID int64
|
var cursorID int64
|
||||||
for {
|
for {
|
||||||
cond := baseCond
|
sess := JobQueueOptions{RepoID: runner.RepoID, OwnerID: runner.OwnerID, Status: StatusWaiting}.session(ctx)
|
||||||
if cursorID > 0 {
|
if cursorID > 0 {
|
||||||
cond = cond.And(builder.Or(
|
sess.And(builder.Or(
|
||||||
builder.Gt{"updated": cursorUpdated},
|
builder.Gt{"updated": cursorUpdated},
|
||||||
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
builder.And(builder.Eq{"updated": cursorUpdated}, builder.Gt{"id": cursorID}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
var jobs []*ActionRunJob
|
var jobs []*ActionRunJob
|
||||||
if err := e.Where(cond).Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
if err := sess.Asc("updated", "id").Limit(pickTaskBatchSize).Find(&jobs); err != nil {
|
||||||
return nil, false, err
|
return nil, cancelledJobs, err
|
||||||
}
|
}
|
||||||
// A short page means no waiting jobs remain beyond it.
|
// A short page means no waiting jobs remain beyond it.
|
||||||
isLastPage := len(jobs) < pickTaskBatchSize
|
isLastPage := len(jobs) < pickTaskBatchSize
|
||||||
@@ -309,28 +298,29 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask
|
|||||||
if !runner.CanMatchLabels(v.RunsOn) {
|
if !runner.CanMatchLabels(v.RunsOn) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
task, ok, err := claimJobForRunner(ctx, runner, v)
|
task, cancelled, err := claimJobForRunner(ctx, runner, v)
|
||||||
if err != nil {
|
if cancelled {
|
||||||
return nil, false, err
|
cancelledJobs = append(cancelledJobs, v)
|
||||||
}
|
}
|
||||||
if ok {
|
if err != nil || task != nil {
|
||||||
return task, true, nil
|
return task, cancelledJobs, err
|
||||||
}
|
}
|
||||||
// Another runner claimed this job concurrently; try the next one.
|
// Another runner claimed this job concurrently, or it was cancelled. Try the next one.
|
||||||
}
|
}
|
||||||
|
|
||||||
if isLastPage {
|
if isLastPage {
|
||||||
return nil, false, nil
|
return nil, cancelledJobs, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// claimJobForRunner attempts to atomically claim job for runner inside its own
|
// claimJobForRunner attempts to atomically claim job for runner inside its own
|
||||||
// transaction. Returns (task, true, nil) on success, or (nil, false, nil) when
|
// transaction. Returns the task on success, cancelled when the job's repository
|
||||||
// another runner wins the optimistic-lock race (the caller should try the next
|
// has Actions disabled, or neither when another runner wins the optimistic-lock
|
||||||
// candidate job).
|
// race (the caller should try the next candidate job).
|
||||||
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
|
func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRunJob) (*ActionTask, bool, error) {
|
||||||
var resultTask *ActionTask
|
var resultTask *ActionTask
|
||||||
|
var cancelled bool
|
||||||
|
|
||||||
err := db.WithTx(ctx, func(ctx context.Context) error {
|
err := db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
e := db.GetEngine(ctx)
|
e := db.GetEngine(ctx)
|
||||||
@@ -338,6 +328,15 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
|
|||||||
if err := job.LoadAttributes(ctx); err != nil {
|
if err := job.LoadAttributes(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := job.Run.Repo.LoadUnits(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !job.Run.Repo.UnitEnabled(ctx, unit.TypeActions) {
|
||||||
|
job.Status, job.Stopped = StatusCancelled, timeutil.TimeStampNow()
|
||||||
|
affected, err := UpdateRunJob(ctx, job, builder.Eq{"task_id": 0, "status": StatusWaiting}, "status", "stopped")
|
||||||
|
cancelled = err == nil && affected == 1
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
now := timeutil.TimeStampNow()
|
now := timeutil.TimeStampNow()
|
||||||
job.Started = now
|
job.Started = now
|
||||||
@@ -403,13 +402,10 @@ func claimJobForRunner(ctx context.Context, runner *ActionRunner, job *ActionRun
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if errors.Is(err, errJobAlreadyClaimed) {
|
if err != nil && !errors.Is(err, errJobAlreadyClaimed) {
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
return resultTask, true, nil
|
return resultTask, cancelled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes
|
// ReleaseTaskForRunner reverts a freshly-claimed but undelivered task: it deletes
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
|
"gitea.dev/models/unit"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
"gitea.dev/modules/actions/jobparser"
|
"gitea.dev/modules/actions/jobparser"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
@@ -94,14 +95,14 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "very long name truncated",
|
name: "very long name truncated",
|
||||||
jobStep: &jobparser.Step{
|
jobStep: &jobparser.Step{
|
||||||
Name: jobparser.BlockSafeString(strings.Repeat("a", 300)),
|
Name: strings.Repeat("a", 300),
|
||||||
},
|
},
|
||||||
expected: strings.Repeat("a", 252) + "…",
|
expected: strings.Repeat("a", 252) + "…",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "very long run truncated",
|
name: "very long run truncated",
|
||||||
jobStep: &jobparser.Step{
|
jobStep: &jobparser.Step{
|
||||||
Run: jobparser.BlockSafeString(strings.Repeat("a", 300)),
|
Run: strings.Repeat("a", 300),
|
||||||
},
|
},
|
||||||
expected: "Run " + strings.Repeat("a", 248) + "…",
|
expected: "Run " + strings.Repeat("a", 248) + "…",
|
||||||
},
|
},
|
||||||
@@ -258,6 +259,11 @@ func TestReleaseTaskForRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Insert(t.Context(), run))
|
require.NoError(t, db.Insert(t.Context(), run))
|
||||||
|
|
||||||
|
disabledRun := &ActionRun{RepoID: 2, TriggerUserID: 2}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), disabledRun))
|
||||||
|
disabledJob := &ActionRunJob{RunID: disabledRun.ID, RepoID: 2, Status: StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), disabledJob))
|
||||||
|
|
||||||
job := &ActionRunJob{
|
job := &ActionRunJob{
|
||||||
RunID: run.ID,
|
RunID: run.ID,
|
||||||
RepoID: run.RepoID,
|
RepoID: run.RepoID,
|
||||||
@@ -279,11 +285,13 @@ func TestReleaseTaskForRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
runner.GenerateAndFillToken()
|
runner.GenerateAndFillToken()
|
||||||
require.NoError(t, db.Insert(t.Context(), runner))
|
require.NoError(t, db.Insert(t.Context(), runner))
|
||||||
|
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: run.RepoID, Type: unit.TypeActions}))
|
||||||
|
|
||||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
task, cancelledJobs, err := CreateTaskForRunner(t.Context(), runner)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, ok)
|
|
||||||
require.NotNil(t, task)
|
require.NotNil(t, task)
|
||||||
|
require.Len(t, cancelledJobs, 1)
|
||||||
|
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: disabledJob.ID}).Status)
|
||||||
|
|
||||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||||
require.Equal(t, StatusRunning, claimed.Status)
|
require.Equal(t, StatusRunning, claimed.Status)
|
||||||
@@ -324,6 +332,11 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Insert(t.Context(), run))
|
require.NoError(t, db.Insert(t.Context(), run))
|
||||||
|
|
||||||
|
disabledRun := &ActionRun{RepoID: 2, TriggerUserID: 2}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), disabledRun))
|
||||||
|
disabledJob := &ActionRunJob{RunID: disabledRun.ID, RepoID: 2, Status: StatusWaiting, RunsOn: []string{"ubuntu-latest"}}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), disabledJob))
|
||||||
|
|
||||||
// Five waiting jobs the runner cannot run, then one it can.
|
// Five waiting jobs the runner cannot run, then one it can.
|
||||||
// With a page size of 2 the matching job only appears on the third page.
|
// With a page size of 2 the matching job only appears on the third page.
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
@@ -362,11 +375,13 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
runner.GenerateAndFillToken()
|
runner.GenerateAndFillToken()
|
||||||
require.NoError(t, db.Insert(t.Context(), runner))
|
require.NoError(t, db.Insert(t.Context(), runner))
|
||||||
|
require.NoError(t, db.Insert(t.Context(), &repo_model.RepoUnit{RepoID: run.RepoID, Type: unit.TypeActions}))
|
||||||
|
|
||||||
task, ok, err := CreateTaskForRunner(t.Context(), runner)
|
task, cancelledJobs, err := CreateTaskForRunner(t.Context(), runner)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, ok)
|
|
||||||
require.NotNil(t, task)
|
require.NotNil(t, task)
|
||||||
|
require.Len(t, cancelledJobs, 1)
|
||||||
|
assert.Equal(t, StatusCancelled, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: disabledJob.ID}).Status)
|
||||||
|
|
||||||
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
|
claimed := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: target.ID})
|
||||||
assert.Equal(t, StatusRunning, claimed.Status)
|
assert.Equal(t, StatusRunning, claimed.Status)
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (opts FindGPGKeyOptions) ToConds() builder.Cond {
|
|||||||
cond = cond.And(builder.Eq{"primary_key_id": ""})
|
cond = cond.And(builder.Eq{"primary_key_id": ""})
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID != 0 {
|
||||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||||
}
|
}
|
||||||
if opts.KeyID != "" {
|
if opts.KeyID != "" {
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func SearchPrincipalKey(ctx context.Context, principalKey string) (*PublicKey, e
|
|||||||
// In the future, the existing principal keys should be migrated to use the fingerprint ("principal:{name}") instead of the content
|
// In the future, the existing principal keys should be migrated to use the fingerprint ("principal:{name}") instead of the content
|
||||||
key := new(PublicKey)
|
key := new(PublicKey)
|
||||||
has, err := db.GetEngine(ctx).
|
has, err := db.GetEngine(ctx).
|
||||||
Where("content = ?", principalKey).
|
Where("content = ? AND type = ?", principalKey, KeyTypePrincipal).
|
||||||
Get(key)
|
Get(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -224,7 +224,7 @@ func (opts FindPublicKeyOptions) ToOrders() string {
|
|||||||
|
|
||||||
func (opts FindPublicKeyOptions) ToConds() builder.Cond {
|
func (opts FindPublicKeyOptions) ToConds() builder.Cond {
|
||||||
cond := builder.NewCond()
|
cond := builder.NewCond()
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID != 0 {
|
||||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||||
}
|
}
|
||||||
if opts.Fingerprint != "" {
|
if opts.Fingerprint != "" {
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
|
||||||
"github.com/42wim/sshsig"
|
"github.com/42wim/sshsig"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_SSHParsePublicKey(t *testing.T) {
|
func Test_SSHParsePublicKey(t *testing.T) {
|
||||||
@@ -481,6 +483,20 @@ func TestPublicKeysAreExternallyManaged(t *testing.T) {
|
|||||||
assert.False(t, externals[0])
|
assert.False(t, externals[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchPrincipalKeyIgnoresOtherKeyTypes(t *testing.T) {
|
||||||
|
require.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
userKey := unittest.AssertExistsAndLoadBean(t, &PublicKey{ID: 1})
|
||||||
|
|
||||||
|
_, err := SearchPrincipalKey(t.Context(), userKey.Content)
|
||||||
|
assert.True(t, IsErrKeyNotExist(err))
|
||||||
|
|
||||||
|
principal := &PublicKey{OwnerID: 1, Name: userKey.Content, Content: userKey.Content, Type: KeyTypePrincipal}
|
||||||
|
require.NoError(t, db.Insert(t.Context(), principal))
|
||||||
|
key, err := SearchPrincipalKey(t.Context(), userKey.Content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, principal.ID, key.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations.
|
// TestCheckPublicKeyStringOversized tests if oversized SSH2 public key strings are rejected before triggering costly operations.
|
||||||
func TestCheckPublicKeyStringOversized(t *testing.T) {
|
func TestCheckPublicKeyStringOversized(t *testing.T) {
|
||||||
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
|
_, err := parseKeyString(strings.Repeat("a", maxKeyContentBytes+1))
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ func GetAuthTokenByID(ctx context.Context, id string) (*AuthToken, error) {
|
|||||||
return at, nil
|
return at, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken) error {
|
func UpdateAuthTokenByID(ctx context.Context, t *AuthToken, oldHash string) (bool, error) {
|
||||||
_, err := db.GetEngine(ctx).ID(t.ID).Cols("token_hash", "expires_unix").Update(t)
|
affected, err := db.GetEngine(ctx).ID(t.ID).Where("token_hash = ?", oldHash).
|
||||||
return err
|
Cols("token_hash", "expires_unix").Update(t)
|
||||||
|
return affected == 1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteAuthTokenByID(ctx context.Context, id string) error {
|
func DeleteAuthTokenByID(ctx context.Context, id string) error {
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
|
|||||||
DisplayName: "Gitea App",
|
DisplayName: "Gitea App",
|
||||||
RedirectURIs: []string{"com.gitea.app://oauth/callback"},
|
RedirectURIs: []string{"com.gitea.app://oauth/callback"},
|
||||||
}
|
}
|
||||||
|
m["75a9ec3e-42b5-40cb-9144-9ea96ecd56a6"] = &BuiltinOAuth2Application{
|
||||||
|
ConfigName: "gitea-vscode",
|
||||||
|
DisplayName: "gitea-vscode",
|
||||||
|
RedirectURIs: []string{
|
||||||
|
"http://127.0.0.1",
|
||||||
|
"https://127.0.0.1",
|
||||||
|
"vscode://gitea.gitea-for-vscode/auth/callback",
|
||||||
|
"vscode-insiders://gitea.gitea-for-vscode/auth/callback",
|
||||||
|
},
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ func GetNextCommitStatusIndex(ctx context.Context, repoID int64, sha string) (in
|
|||||||
|
|
||||||
func (status *CommitStatus) loadRepository(ctx context.Context) (err error) {
|
func (status *CommitStatus) loadRepository(ctx context.Context) (err error) {
|
||||||
if status.Repo == nil {
|
if status.Repo == nil {
|
||||||
status.Repo, err = repo_model.GetRepositoryByID(ctx, status.RepoID)
|
status.Repo, err = cache.GetWithContextCache(ctx, cachegroup.Repository, status.RepoID, repo_model.GetRepositoryByID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err)
|
return fmt.Errorf("getRepositoryByID [%d]: %w", status.RepoID, err)
|
||||||
}
|
}
|
||||||
@@ -230,11 +230,9 @@ func (status *CommitStatus) cutTargetURLGiteaActionsPrefix(ctx context.Context)
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
if status.Repo == nil {
|
if err := status.loadRepository(ctx); err != nil {
|
||||||
if err := status.loadRepository(ctx); err != nil {
|
log.Error("loadRepository: %v", err)
|
||||||
log.Error("loadRepository: %v", err)
|
return "", false
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := status.Repo.Link() + "/actions"
|
prefix := status.Repo.Link() + "/actions"
|
||||||
@@ -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 {
|
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),
|
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
|
func(ctx context.Context, _key string) (access_model.Permission, error) {
|
||||||
if err := status.loadRepository(ctx); err != nil {
|
|
||||||
return access_model.Permission{}, err
|
|
||||||
}
|
|
||||||
return access_model.GetDoerRepoPermission(ctx, status.Repo, doer)
|
return access_model.GetDoerRepoPermission(ctx, status.Repo, doer)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
|
"gitea.dev/modules/cache"
|
||||||
"gitea.dev/modules/commitstatus"
|
"gitea.dev/modules/commitstatus"
|
||||||
"gitea.dev/modules/git"
|
"gitea.dev/modules/git"
|
||||||
|
|
||||||
@@ -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.Equal(t, visibleURL, statuses[0].TargetURL)
|
||||||
assert.Empty(t, statuses[1].TargetURL)
|
assert.Empty(t, statuses[1].TargetURL)
|
||||||
assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL)
|
assert.Equal(t, "https://mycicd.org/1", statuses[2].TargetURL)
|
||||||
|
assert.NotNil(t, statuses[0].Repo)
|
||||||
|
assert.Same(t, statuses[0].Repo, statuses[2].Repo)
|
||||||
}
|
}
|
||||||
@@ -99,8 +99,8 @@ func (protectBranch *ProtectedBranch) loadGlob() {
|
|||||||
var err error
|
var err error
|
||||||
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
|
protectBranch.globRule, err = glob.Compile(protectBranch.RuleName, '/')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
|
log.Debug("Invalid glob rule for ProtectedBranch[%d]: %s %v", protectBranch.ID, protectBranch.RuleName, err)
|
||||||
protectBranch.globRule = glob.MustCompile(glob.QuoteMeta(protectBranch.RuleName), '/')
|
protectBranch.globRule, _ = glob.Compile(glob.QuoteMeta(protectBranch.RuleName), '/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,9 @@ func (protectBranch *ProtectedBranch) Match(branchName string) bool {
|
|||||||
if protectBranch.isPlainName {
|
if protectBranch.isPlainName {
|
||||||
return strings.EqualFold(protectBranch.RuleName, branchName)
|
return strings.EqualFold(protectBranch.RuleName, branchName)
|
||||||
}
|
}
|
||||||
|
if protectBranch.globRule == nil {
|
||||||
|
return false // in case of invalid glob rule, we don't match anything
|
||||||
|
}
|
||||||
return protectBranch.globRule.Match(branchName)
|
return protectBranch.globRule.Match(branchName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/modules/glob"
|
"gitea.dev/modules/glob"
|
||||||
|
"gitea.dev/modules/log"
|
||||||
"gitea.dev/modules/optional"
|
"gitea.dev/modules/optional"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,10 +56,17 @@ func FindRepoProtectedBranchRules(ctx context.Context, repoID int64) (ProtectedB
|
|||||||
// FindAllMatchedBranches find all matched branches
|
// FindAllMatchedBranches find all matched branches
|
||||||
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
|
func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string) ([]string, error) {
|
||||||
results := make([]string, 0, 10)
|
results := make([]string, 0, 10)
|
||||||
|
rule, err := glob.Compile(ruleName)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("Failed to compile rule %s: %v", ruleName, err)
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = 100
|
||||||
for page := 1; ; page++ {
|
for page := 1; ; page++ {
|
||||||
brancheNames, err := FindBranchNames(ctx, FindBranchOptions{
|
branchNames, err := FindBranchNames(ctx, FindBranchOptions{
|
||||||
ListOptions: db.ListOptions{
|
ListOptions: db.ListOptions{
|
||||||
PageSize: 100,
|
PageSize: pageSize,
|
||||||
Page: page,
|
Page: page,
|
||||||
},
|
},
|
||||||
RepoID: repoID,
|
RepoID: repoID,
|
||||||
@@ -67,14 +75,12 @@ func FindAllMatchedBranches(ctx context.Context, repoID int64, ruleName string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rule := glob.MustCompile(ruleName)
|
for _, branch := range branchNames {
|
||||||
|
|
||||||
for _, branch := range brancheNames {
|
|
||||||
if rule.Match(branch) {
|
if rule.Match(branch) {
|
||||||
results = append(results, branch)
|
results = append(results, branch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(brancheNames) < 100 {
|
if len(branchNames) < pageSize {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrCommentNotExist represents a "CommentNotExist" kind of error.
|
// ErrCommentNotExist represents a "CommentNotExist" kind of error.
|
||||||
@@ -369,12 +370,19 @@ func (c *Comment) LoadIssue(ctx context.Context) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ xorm.BeforeInsertProcessor = (*Comment)(nil)
|
||||||
|
_ xorm.BeforeUpdateProcessor = (*Comment)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
// BeforeInsert will be invoked by XORM before inserting a record
|
// BeforeInsert will be invoked by XORM before inserting a record
|
||||||
func (c *Comment) BeforeInsert() {
|
func (c *Comment) BeforeInsert() {
|
||||||
c.PatchQuoted = c.Patch
|
c.PatchQuoted = c.Patch
|
||||||
if !utf8.ValidString(c.Patch) {
|
if !utf8.ValidString(c.Patch) {
|
||||||
c.PatchQuoted = strconv.Quote(c.Patch)
|
c.PatchQuoted = strconv.Quote(c.Patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sanitizeDatabaseStringContent(&c.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BeforeUpdate will be invoked by XORM before updating a record
|
// BeforeUpdate will be invoked by XORM before updating a record
|
||||||
@@ -383,6 +391,8 @@ func (c *Comment) BeforeUpdate() {
|
|||||||
if !utf8.ValidString(c.Patch) {
|
if !utf8.ValidString(c.Patch) {
|
||||||
c.PatchQuoted = strconv.Quote(c.Patch)
|
c.PatchQuoted = strconv.Quote(c.Patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sanitizeDatabaseStringContent(&c.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
|
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
|
||||||
@@ -1164,6 +1174,11 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us
|
|||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
return ErrCommentAlreadyChanged
|
return ErrCommentAlreadyChanged
|
||||||
}
|
}
|
||||||
|
if c.Type == CommentTypeReview {
|
||||||
|
if _, err := db.GetEngine(ctx).ID(c.ReviewID).Cols("content").NoAutoTime().Update(&Review{Content: c.Content}); err != nil { // updated_unix picks a reviewer's latest review
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := c.LoadIssue(ctx); err != nil {
|
if err := c.LoadIssue(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-4
@@ -11,6 +11,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
project_model "gitea.dev/models/project"
|
project_model "gitea.dev/models/project"
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrIssueNotExist represents a "IssueNotExist" kind of error.
|
// ErrIssueNotExist represents a "IssueNotExist" kind of error.
|
||||||
@@ -53,7 +55,7 @@ var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already
|
|||||||
// Issue represents an issue or pull request of repository.
|
// Issue represents an issue or pull request of repository.
|
||||||
type Issue struct {
|
type Issue struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"`
|
RepoID int64 `xorm:"INDEX(repo_closed_pull) UNIQUE(repo_index)"`
|
||||||
Repo *repo_model.Repository `xorm:"-"`
|
Repo *repo_model.Repository `xorm:"-"`
|
||||||
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
|
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
|
||||||
PosterID int64 `xorm:"INDEX"`
|
PosterID int64 `xorm:"INDEX"`
|
||||||
@@ -75,13 +77,13 @@ type Issue struct {
|
|||||||
AssigneeID int64 `xorm:"-"`
|
AssigneeID int64 `xorm:"-"`
|
||||||
Assignee *user_model.User `xorm:"-"`
|
Assignee *user_model.User `xorm:"-"`
|
||||||
isAssigneeLoaded bool `xorm:"-"`
|
isAssigneeLoaded bool `xorm:"-"`
|
||||||
IsClosed bool `xorm:"INDEX"`
|
IsClosed bool `xorm:"INDEX(repo_closed_pull)"`
|
||||||
IsRead bool `xorm:"-"`
|
IsRead bool `xorm:"-"`
|
||||||
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
|
IsPull bool `xorm:"INDEX(repo_closed_pull)"` // Indicates whether is a pull request or not.
|
||||||
PullRequest *PullRequest `xorm:"-"`
|
PullRequest *PullRequest `xorm:"-"`
|
||||||
NumComments int
|
NumComments int
|
||||||
|
|
||||||
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
|
// TODO: ISSUE-SIDEBAR-REF-SELECTOR: see "repo/issue/branch_selector_field.tmpl"
|
||||||
Ref string
|
Ref string
|
||||||
|
|
||||||
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
|
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
|
||||||
@@ -123,6 +125,27 @@ func init() {
|
|||||||
db.RegisterModel(new(IssueIndex))
|
db.RegisterModel(new(IssueIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sanitizeDatabaseStringContent(s *string) {
|
||||||
|
// postgresql doesn't like "\x00"
|
||||||
|
// this is only a simple workaround for issues like https://github.com/go-gitea/gitea/issues/28890 and https://github.com/go-gitea/gitea/issues/40003
|
||||||
|
*s = strings.ReplaceAll(*s, "\x00", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ xorm.BeforeInsertProcessor = (*Issue)(nil)
|
||||||
|
_ xorm.BeforeUpdateProcessor = (*Issue)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (issue *Issue) BeforeInsert() {
|
||||||
|
sanitizeDatabaseStringContent(&issue.Title)
|
||||||
|
sanitizeDatabaseStringContent(&issue.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (issue *Issue) BeforeUpdate() {
|
||||||
|
sanitizeDatabaseStringContent(&issue.Title)
|
||||||
|
sanitizeDatabaseStringContent(&issue.Content)
|
||||||
|
}
|
||||||
|
|
||||||
// LoadTotalTimes load total tracked time
|
// LoadTotalTimes load total tracked time
|
||||||
func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
|
func (issue *Issue) LoadTotalTimes(ctx context.Context) (err error) {
|
||||||
opts := FindTrackedTimesOptions{IssueID: issue.ID}
|
opts := FindTrackedTimesOptions{IssueID: issue.ID}
|
||||||
|
|||||||
@@ -75,11 +75,11 @@ func applySorts(sess db.Session, sortType string, priorityRepoID int64) {
|
|||||||
// Since this sortType is dynamically created, it has to be treated specially.
|
// Since this sortType is dynamically created, it has to be treated specially.
|
||||||
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
|
if after, ok := strings.CutPrefix(sortType, ScopeSortPrefix); ok {
|
||||||
scope := after
|
scope := after
|
||||||
sess.Join("LEFT", "issue_label", "issue.id = issue_label.issue_id")
|
// a subquery instead of a JOIN keeps one row per issue, "exclusive_order=0" means unset and COALESCE sorts it last on every DB
|
||||||
// "exclusive_order=0" means "no order is set", so exclude it from the JOIN criteria and then "LEFT JOIN" result is also null
|
sess.OrderBy("COALESCE((SELECT MIN(label.exclusive_order) FROM issue_label"+
|
||||||
sess.Join("LEFT", "label", "label.id = issue_label.label_id AND label.exclusive_order <> 0 AND label.name LIKE ?", scope+"/%")
|
" INNER JOIN label ON label.id = issue_label.label_id"+
|
||||||
// Use COALESCE to make sure we sort NULL last regardless of backend DB (2147483647 == max int)
|
" WHERE issue_label.issue_id = issue.id AND label.exclusive_order <> 0 AND label.name LIKE ?), 2147483647) ASC", scope+"/%").
|
||||||
sess.OrderBy("COALESCE(label.exclusive_order, 2147483647) ASC").Desc("issue.id")
|
Desc("issue.id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,10 +218,7 @@ func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
|
|||||||
opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
|
opts.RepoCond = builder.In("issue.repo_id", opts.RepoIDs)
|
||||||
}
|
}
|
||||||
if opts.AllPublic {
|
if opts.AllPublic {
|
||||||
if opts.RepoCond == nil {
|
opts.RepoCond = builder.Or(opts.RepoCond, repo_model.PublicRepoUnderPublicOwnerCond())
|
||||||
opts.RepoCond = builder.NewCond()
|
|
||||||
}
|
|
||||||
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond())))
|
|
||||||
}
|
}
|
||||||
if opts.RepoCond != nil {
|
if opts.RepoCond != nil {
|
||||||
sess.And(opts.RepoCond)
|
sess.And(opts.RepoCond)
|
||||||
@@ -402,22 +399,17 @@ func applyReviewRequestedCondition(sess db.Session, reviewRequestedID int64) {
|
|||||||
From("team_user").
|
From("team_user").
|
||||||
Where(builder.Eq{"team_user.uid": reviewRequestedID})
|
Where(builder.Eq{"team_user.uid": reviewRequestedID})
|
||||||
|
|
||||||
// if the review is approved or rejected, it should not be shown in the review requested list
|
|
||||||
maxReview := builder.Select("MAX(r.id)").
|
|
||||||
From("review as r").
|
|
||||||
Where(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})).
|
|
||||||
GroupBy("r.issue_id, r.reviewer_id, r.reviewer_team_id")
|
|
||||||
|
|
||||||
subQuery := builder.Select("review.issue_id").
|
subQuery := builder.Select("review.issue_id").
|
||||||
From("review").
|
From("review").
|
||||||
Where(builder.And(
|
Where(builder.Eq{"review.type": ReviewTypeRequest}).
|
||||||
builder.Eq{"review.type": ReviewTypeRequest},
|
And(builder.Or(
|
||||||
builder.Or(
|
builder.Eq{"review.reviewer_id": reviewRequestedID},
|
||||||
builder.Eq{"review.reviewer_id": reviewRequestedID},
|
builder.In("review.reviewer_team_id", existInTeamQuery),
|
||||||
builder.In("review.reviewer_team_id", existInTeamQuery),
|
)).
|
||||||
),
|
// if the review is approved or rejected, it should not be shown in the review requested list
|
||||||
builder.In("review.id", maxReview),
|
And(builder.NotExists(builder.Select("1").From("review as r").
|
||||||
))
|
Where(builder.Expr("r.issue_id = review.issue_id AND r.reviewer_id = review.reviewer_id AND r.reviewer_team_id = review.reviewer_team_id AND r.id > review.id").
|
||||||
|
And(builder.In("r.type", []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest})))))
|
||||||
sess.Where("issue.poster_id <> ?", reviewRequestedID).
|
sess.Where("issue.poster_id <> ?", reviewRequestedID).
|
||||||
And(builder.In("issue.id", subQuery))
|
And(builder.In("issue.id", subQuery))
|
||||||
}
|
}
|
||||||
@@ -477,7 +469,7 @@ func applySubscribedCondition(sess db.Session, subscriberID int64) {
|
|||||||
Select("repo_id").
|
Select("repo_id").
|
||||||
From("watch").
|
From("watch").
|
||||||
Where(builder.And(builder.Eq{"user_id": subscriberID},
|
Where(builder.And(builder.Eq{"user_id": subscriberID},
|
||||||
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))),
|
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto, repo_model.WatchModeCustom))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+14
-18
@@ -269,27 +269,23 @@ func UpdateLabel(ctx context.Context, l *Label) error {
|
|||||||
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
|
return updateLabelCols(ctx, l, "name", "description", "color", "exclusive", "exclusive_order", "archived_unix")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLabel delete a label
|
// DeleteRepoLabel deletes a repository label.
|
||||||
func DeleteLabel(ctx context.Context, id, labelID int64) error {
|
func DeleteRepoLabel(ctx context.Context, repoID, labelID int64) error {
|
||||||
l, err := GetLabelByID(ctx, labelID)
|
return deleteLabel(ctx, labelID, builder.Eq{"repo_id": repoID, "org_id": 0})
|
||||||
if err != nil {
|
}
|
||||||
if IsErrLabelNotExist(err) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// DeleteOrgLabel deletes an organization label.
|
||||||
|
func DeleteOrgLabel(ctx context.Context, orgID, labelID int64) error {
|
||||||
|
return deleteLabel(ctx, labelID, builder.Eq{"org_id": orgID, "repo_id": 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteLabel(ctx context.Context, labelID int64, ownerCond builder.Cond) error {
|
||||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||||
if l.BelongsToOrg() && l.OrgID != id {
|
deleted, err := db.GetEngine(ctx).ID(labelID).Where(ownerCond).Delete(new(Label))
|
||||||
return nil
|
if err != nil || deleted == 0 {
|
||||||
}
|
|
||||||
if l.BelongsToRepo() && l.RepoID != id {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err = db.DeleteByID[Label](ctx, labelID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
} else if _, err = db.GetEngine(ctx).
|
}
|
||||||
|
if _, err = db.GetEngine(ctx).
|
||||||
Where("label_id = ?", labelID).
|
Where("label_id = ?", labelID).
|
||||||
Delete(new(IssueLabel)); err != nil {
|
Delete(new(IssueLabel)); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -320,13 +320,29 @@ func TestUpdateLabel(t *testing.T) {
|
|||||||
func TestDeleteLabel(t *testing.T) {
|
func TestDeleteLabel(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
|
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 1})
|
||||||
assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID))
|
orgLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 3})
|
||||||
|
repoLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 10, RepoID: orgLabel.OrgID})
|
||||||
|
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), repoLabel.RepoID, repoLabel.ID))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: repoLabel.ID})
|
||||||
|
|
||||||
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), orgLabel.OrgID, orgLabel.ID))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: orgLabel.ID})
|
||||||
|
|
||||||
|
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), unittest.NonexistentID, orgLabel.ID))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: orgLabel.ID})
|
||||||
|
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), orgLabel.OrgID, orgLabel.ID))
|
||||||
|
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: orgLabel.ID})
|
||||||
|
|
||||||
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), unittest.NonexistentID, label.ID))
|
||||||
|
unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: label.ID})
|
||||||
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), label.RepoID, label.ID))
|
||||||
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID})
|
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID, RepoID: label.RepoID})
|
||||||
|
|
||||||
assert.NoError(t, issues_model.DeleteLabel(t.Context(), label.RepoID, label.ID))
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), label.RepoID, label.ID))
|
||||||
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID})
|
unittest.AssertNotExistsBean(t, &issues_model.Label{ID: label.ID})
|
||||||
|
|
||||||
assert.NoError(t, issues_model.DeleteLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
|
assert.NoError(t, issues_model.DeleteRepoLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
|
||||||
|
assert.NoError(t, issues_model.DeleteOrgLabel(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
|
||||||
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
|
unittest.CheckConsistencyFor(t, &issues_model.Label{}, &repo_model.Repository{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,13 @@ func (milestones MilestoneList) SplitByOpenClosed() (open, closed MilestoneList)
|
|||||||
// FindMilestoneOptions contain options to get milestones
|
// FindMilestoneOptions contain options to get milestones
|
||||||
type FindMilestoneOptions struct {
|
type FindMilestoneOptions struct {
|
||||||
db.ListOptions
|
db.ListOptions
|
||||||
RepoID int64
|
RepoID int64
|
||||||
IsClosed optional.Option[bool]
|
IsClosed optional.Option[bool]
|
||||||
Name string
|
Name string
|
||||||
SortType string
|
SortType string
|
||||||
RepoCond builder.Cond
|
RepoCond builder.Cond
|
||||||
RepoIDs []int64
|
RepoIDs []int64
|
||||||
|
ProjectID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (opts FindMilestoneOptions) ToConds() builder.Cond {
|
func (opts FindMilestoneOptions) ToConds() builder.Cond {
|
||||||
@@ -64,6 +65,11 @@ func (opts FindMilestoneOptions) ToConds() builder.Cond {
|
|||||||
if len(opts.Name) != 0 {
|
if len(opts.Name) != 0 {
|
||||||
cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name))
|
cond = cond.And(db.BuildCaseInsensitiveLike("name", opts.Name))
|
||||||
}
|
}
|
||||||
|
if opts.ProjectID != 0 {
|
||||||
|
cond = cond.And(builder.In("id", builder.Select("issue.milestone_id").From("issue").
|
||||||
|
InnerJoin("project_issue", "project_issue.issue_id = issue.id").
|
||||||
|
Where(builder.Eq{"project_issue.project_id": opts.ProjectID})))
|
||||||
|
}
|
||||||
|
|
||||||
return cond
|
return cond
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ func (pr *PullRequest) getReviewedByLines(ctx context.Context, writer io.Writer)
|
|||||||
reviewersWritten := 0
|
reviewersWritten := 0
|
||||||
|
|
||||||
for _, review := range reviews {
|
for _, review := range reviews {
|
||||||
if maxReviewers > 0 && reviewersWritten > maxReviewers {
|
if maxReviewers > 0 && reviewersWritten >= maxReviewers {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -335,8 +335,8 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
|
|||||||
"reviewer_team_id": 0,
|
"reviewer_team_id": 0,
|
||||||
}).
|
}).
|
||||||
GroupBy("issue_id, reviewer_id")
|
GroupBy("issue_id, reviewer_id")
|
||||||
// Get latest review of each reviewer, sorted in order they were made
|
// Get latest review of each reviewer, sorted in order they were made. Joined because MySQL scans all reviews for "id IN (subquery)", xorm aliases the subquery "review".
|
||||||
if err := db.GetEngine(ctx).In("id", subQuery).OrderBy("review.updated_unix ASC").Find(&reviews); err != nil {
|
if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQuery, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&reviews); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ func (prs PullRequestList) LoadReviews(ctx context.Context) (ReviewList, error)
|
|||||||
"reviewer_team_id": 0,
|
"reviewer_team_id": 0,
|
||||||
}).
|
}).
|
||||||
GroupBy("issue_id, reviewer_team_id")
|
GroupBy("issue_id, reviewer_team_id")
|
||||||
if err := db.GetEngine(ctx).In("id", subQueryTeam).OrderBy("review.updated_unix ASC").Find(&teamReviewRequests); err != nil {
|
if err := db.GetEngine(ctx).Table("review").Alias("latest").Join("INNER", subQueryTeam, "latest.id = review.id").OrderBy("latest.updated_unix ASC").Find(&teamReviewRequests); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
user_model "gitea.dev/models/user"
|
user_model "gitea.dev/models/user"
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -403,6 +404,10 @@ func testGetApprovers(t *testing.T) {
|
|||||||
approvers := pr.GetApprovers(t.Context())
|
approvers := pr.GetApprovers(t.Context())
|
||||||
expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n"
|
expected := "Reviewed-by: User Five <user5@example.com>\nReviewed-by: Org Six <org6@example.com>\n"
|
||||||
assert.Equal(t, expected, approvers)
|
assert.Equal(t, expected, approvers)
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.Repository.PullRequest.DefaultMergeMessageMaxApprovers, 1)()
|
||||||
|
approvers = pr.GetApprovers(t.Context())
|
||||||
|
assert.Equal(t, "Reviewed-by: User Five <user5@example.com>\n", approvers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGetPullRequestByMergedCommit(t *testing.T) {
|
func testGetPullRequestByMergedCommit(t *testing.T) {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func queryUserOrgIDs(userID int64, includePrivate bool) *builder.Builder {
|
|||||||
|
|
||||||
func (opts FindOrgOptions) ToConds() builder.Cond {
|
func (opts FindOrgOptions) ToConds() builder.Cond {
|
||||||
var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization}
|
var cond builder.Cond = builder.Eq{"`user`.`type`": user_model.UserTypeOrganization}
|
||||||
if opts.UserID > 0 {
|
if opts.UserID != 0 {
|
||||||
cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludeVisibility == structs.VisibleTypePrivate)))
|
cond = cond.And(builder.In("`user`.`id`", queryUserOrgIDs(opts.UserID, opts.IncludeVisibility == structs.VisibleTypePrivate)))
|
||||||
}
|
}
|
||||||
// public=0, limited=1, private=2
|
// public=0, limited=1, private=2
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import (
|
|||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TeamRepo represents an team-repository relation.
|
// TeamRepo represents an org-team-repository relation.
|
||||||
type TeamRepo struct {
|
type TeamRepo struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
OrgID int64 `xorm:"INDEX"`
|
OrgID int64 `xorm:"INDEX"`
|
||||||
TeamID int64 `xorm:"UNIQUE(s)"`
|
TeamID int64 `xorm:"UNIQUE(s)"`
|
||||||
RepoID int64 `xorm:"UNIQUE(s)"`
|
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasTeamRepo returns true if given repository belongs to team.
|
// HasTeamRepo returns true if given repository belongs to team.
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import (
|
|||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TeamUser represents an team-user relation.
|
// TeamUser represents a team-user relation.
|
||||||
type TeamUser struct {
|
type TeamUser struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
OrgID int64 `xorm:"INDEX"`
|
OrgID int64 `xorm:"INDEX"`
|
||||||
TeamID int64 `xorm:"UNIQUE(s)"`
|
TeamID int64 `xorm:"UNIQUE(s)"`
|
||||||
UID int64 `xorm:"UNIQUE(s)"`
|
UID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTeamMember returns true if given user is a member of team.
|
// IsTeamMember returns true if given user is a member of team.
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func GetTotalUnreferencedBlobSize(ctx context.Context) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsBlobAccessibleForUser tests if the user has access to the blob
|
// IsBlobAccessibleForUser tests if the user has access to the blob
|
||||||
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User) (bool, error) {
|
func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model.User, publicOnly bool) (bool, error) {
|
||||||
if user.IsAdmin {
|
if user.IsAdmin && !publicOnly {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
|
ownerVisibilities := []structs.VisibleType{structs.VisibleTypePublic}
|
||||||
@@ -153,6 +153,9 @@ func IsBlobAccessibleForUser(ctx context.Context, blobID int64, user *user_model
|
|||||||
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
|
Or(builder.Eq{"`user`.type": user_model.UserTypeOrganization}.
|
||||||
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
|
And(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamAuthorize}.Or(builder.Lte{strconv.Itoa(int(perm.AccessModeRead)): maxTeamUnitAccessMode}))),
|
||||||
)
|
)
|
||||||
|
if publicOnly {
|
||||||
|
cond = cond.And(builder.Eq{"`user`.visibility": structs.VisibleTypePublic})
|
||||||
|
}
|
||||||
|
|
||||||
return db.GetEngine(ctx).
|
return db.GetEngine(ctx).
|
||||||
Table("package_blob").
|
Table("package_blob").
|
||||||
|
|||||||
@@ -64,11 +64,15 @@ func TestIsBlobAccessibleForRestrictedUser(t *testing.T) {
|
|||||||
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
|
_, err = TryInsertFile(t.Context(), &PackageFile{VersionID: version.ID, BlobID: blob.ID, Name: "blob", LowerName: "blob"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}))
|
accessible, err := IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}), false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, accessible)
|
assert.True(t, accessible)
|
||||||
|
|
||||||
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}))
|
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 29}), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, accessible)
|
||||||
|
|
||||||
|
accessible, err = IsBlobAccessibleForUser(t.Context(), blob.ID, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}), true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, accessible)
|
assert.False(t, accessible)
|
||||||
}
|
}
|
||||||
@@ -30,9 +30,9 @@ const (
|
|||||||
// PackageProperty represents a property of a package, version or file
|
// PackageProperty represents a property of a package, version or file
|
||||||
type PackageProperty struct {
|
type PackageProperty struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
RefType PropertyType `xorm:"INDEX NOT NULL"`
|
RefType PropertyType `xorm:"NOT NULL"`
|
||||||
RefID int64 `xorm:"INDEX NOT NULL"`
|
RefID int64 `xorm:"INDEX(ref_id_name) NOT NULL"`
|
||||||
Name string `xorm:"INDEX NOT NULL"`
|
Name string `xorm:"INDEX(ref_id_name) NOT NULL"`
|
||||||
Value string `xorm:"LONGTEXT NOT NULL"`
|
Value string `xorm:"LONGTEXT NOT NULL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
type Access struct {
|
type Access struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
UserID int64 `xorm:"UNIQUE(s)"`
|
UserID int64 `xorm:"UNIQUE(s)"`
|
||||||
RepoID int64 `xorm:"UNIQUE(s)"`
|
RepoID int64 `xorm:"UNIQUE(s) INDEX"`
|
||||||
Mode perm.AccessMode
|
Mode perm.AccessMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func (opts SearchOptions) ToConds() builder.Cond {
|
|||||||
if opts.Type > 0 {
|
if opts.Type > 0 {
|
||||||
cond = cond.And(builder.Eq{"type": opts.Type})
|
cond = cond.And(builder.Eq{"type": opts.Type})
|
||||||
}
|
}
|
||||||
if opts.OwnerID > 0 {
|
if opts.OwnerID != 0 {
|
||||||
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
repo_model "gitea.dev/models/repo"
|
repo_model "gitea.dev/models/repo"
|
||||||
"gitea.dev/models/unittest"
|
"gitea.dev/models/unittest"
|
||||||
"gitea.dev/modules/markup/markdown"
|
"gitea.dev/modules/markup/markdown"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
_ "gitea.dev/modules/markup/orgmode"
|
_ "gitea.dev/modules/markup/orgmode"
|
||||||
|
|
||||||
@@ -80,6 +81,23 @@ func TestRepoFile(t *testing.T) {
|
|||||||
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
|
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
|
||||||
</video>`, rendered)
|
</video>`, rendered)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("PictureSource", func(t *testing.T) {
|
||||||
|
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{CurrentRefSubURL: "branch/main"}).
|
||||||
|
WithMarkupType(markdown.MarkupName)
|
||||||
|
rendered, err := testRenderString(rctx, `<picture><source media="(prefers-color-scheme: dark)" srcset="dark.svg"><img src="light.svg"></picture>`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, test.NormalizeHTMLSpaces(`
|
||||||
|
<p>
|
||||||
|
<a href="/user2/repo1/src/branch/main/light.svg" target="_blank" rel="nofollow noopener">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="/user2/repo1/media/branch/main/dark.svg"/>
|
||||||
|
<img src="/user2/repo1/media/branch/main/light.svg"/>
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
`), test.NormalizeHTMLSpaces(rendered))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRepoFileOrgMode(t *testing.T) {
|
func TestRepoFileOrgMode(t *testing.T) {
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ func ExistAttachmentsByUUID(ctx context.Context, uuid string) (bool, error) {
|
|||||||
|
|
||||||
// GetAttachmentsByIssueID returns all attachments of an issue.
|
// GetAttachmentsByIssueID returns all attachments of an issue.
|
||||||
func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment, error) {
|
func GetAttachmentsByIssueID(ctx context.Context, issueID int64) ([]*Attachment, error) {
|
||||||
|
if issueID == 0 { // unsaved issue, would match every issue-less attachment
|
||||||
|
return []*Attachment{}, nil
|
||||||
|
}
|
||||||
attachments := make([]*Attachment, 0, 10)
|
attachments := make([]*Attachment, 0, 10)
|
||||||
return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments)
|
return attachments, db.GetEngine(ctx).Where("issue_id = ? AND comment_id = 0", issueID).Find(&attachments)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ func TestGetByCommentOrIssueID(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, attachments, 1)
|
assert.Len(t, attachments, 1)
|
||||||
|
|
||||||
|
attachments, err = repo_model.GetAttachmentsByIssueID(t.Context(), 0)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, attachments)
|
||||||
|
|
||||||
attachments, err = repo_model.GetAttachmentsByCommentID(t.Context(), 1)
|
attachments, err = repo_model.GetAttachmentsByCommentID(t.Context(), 1)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, attachments, 2)
|
assert.Len(t, attachments, 2)
|
||||||
|
|||||||
+26
-19
@@ -191,6 +191,10 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i := range attachments {
|
for i := range attachments {
|
||||||
|
if attachments[i].IssueID != 0 || attachments[i].CommentID != 0 || attachments[i].ReleaseID != 0 {
|
||||||
|
return util.NewPermissionDeniedErrorf("attachment is already linked")
|
||||||
|
}
|
||||||
|
|
||||||
if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff {
|
if attachments[i].RepoID == 0 && attachments[i].CreatedUnix < LegacyAttachmentMissingRepoIDCutoff {
|
||||||
attachments[i].RepoID = rel.RepoID
|
attachments[i].RepoID = rel.RepoID
|
||||||
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil {
|
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("repo_id").Update(attachments[i]); err != nil {
|
||||||
@@ -201,10 +205,6 @@ func AddReleaseAttachments(ctx context.Context, releaseID int64, attachmentUUIDs
|
|||||||
if attachments[i].RepoID != rel.RepoID {
|
if attachments[i].RepoID != rel.RepoID {
|
||||||
return util.NewPermissionDeniedErrorf("attachment belongs to different repository")
|
return util.NewPermissionDeniedErrorf("attachment belongs to different repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
if attachments[i].ReleaseID != 0 {
|
|
||||||
return util.NewPermissionDeniedErrorf("release permission denied")
|
|
||||||
}
|
|
||||||
attachments[i].ReleaseID = releaseID
|
attachments[i].ReleaseID = releaseID
|
||||||
// No assign value could be 0, so ignore AllCols().
|
// No assign value could be 0, so ignore AllCols().
|
||||||
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("release_id").Update(attachments[i]); err != nil {
|
if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Cols("release_id").Update(attachments[i]); err != nil {
|
||||||
@@ -357,21 +357,21 @@ func GetLatestReleaseByRepoID(ctx context.Context, repoID int64) (*Release, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
type releaseMetaSearch struct {
|
type releaseMetaSearch struct {
|
||||||
ID []int64
|
IDs []int64
|
||||||
Rel []*Release
|
Releases []*Release
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s releaseMetaSearch) Len() int {
|
func (s releaseMetaSearch) Len() int {
|
||||||
return len(s.ID)
|
return len(s.IDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s releaseMetaSearch) Swap(i, j int) {
|
func (s releaseMetaSearch) Swap(i, j int) {
|
||||||
s.ID[i], s.ID[j] = s.ID[j], s.ID[i]
|
s.IDs[i], s.IDs[j] = s.IDs[j], s.IDs[i]
|
||||||
s.Rel[i], s.Rel[j] = s.Rel[j], s.Rel[i]
|
s.Releases[i], s.Releases[j] = s.Releases[j], s.Releases[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s releaseMetaSearch) Less(i, j int) bool {
|
func (s releaseMetaSearch) Less(i, j int) bool {
|
||||||
return s.ID[i] < s.ID[j]
|
return s.IDs[i] < s.IDs[j]
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasDuplicateName(attaches []*Attachment) bool {
|
func hasDuplicateName(attaches []*Attachment) bool {
|
||||||
@@ -396,19 +396,26 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
|
|||||||
// then merge join them
|
// then merge join them
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
sortedRels := releaseMetaSearch{ID: make([]int64, len(rels)), Rel: make([]*Release, len(rels))}
|
sortedRels := releaseMetaSearch{IDs: make([]int64, 0, len(rels)), Releases: make([]*Release, 0, len(rels))}
|
||||||
var attachments []*Attachment
|
var attachments []*Attachment
|
||||||
for index, element := range rels {
|
for _, rel := range rels {
|
||||||
element.Attachments = []*Attachment{}
|
// if a provided "Release" has no ID, don't query it, otherwise, the "IN" query will return all other attachments for the repo
|
||||||
sortedRels.ID[index] = element.ID
|
if rel.ID == 0 {
|
||||||
sortedRels.Rel[index] = element
|
continue
|
||||||
|
}
|
||||||
|
rel.Attachments = []*Attachment{}
|
||||||
|
sortedRels.IDs = append(sortedRels.IDs, rel.ID)
|
||||||
|
sortedRels.Releases = append(sortedRels.Releases, rel)
|
||||||
|
}
|
||||||
|
if len(sortedRels.IDs) == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
sort.Sort(sortedRels)
|
sort.Sort(sortedRels)
|
||||||
|
|
||||||
// Select attachments
|
// Select attachments
|
||||||
err = db.GetEngine(ctx).
|
err = db.GetEngine(ctx).
|
||||||
Asc("release_id", "name").
|
Asc("release_id", "name").
|
||||||
In("release_id", sortedRels.ID).
|
In("release_id", sortedRels.IDs).
|
||||||
Find(&attachments)
|
Find(&attachments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -417,14 +424,14 @@ func GetReleaseAttachments(ctx context.Context, rels ...*Release) (err error) {
|
|||||||
// merge join
|
// merge join
|
||||||
currentIndex := 0
|
currentIndex := 0
|
||||||
for _, attachment := range attachments {
|
for _, attachment := range attachments {
|
||||||
for sortedRels.ID[currentIndex] < attachment.ReleaseID {
|
for sortedRels.IDs[currentIndex] < attachment.ReleaseID {
|
||||||
currentIndex++
|
currentIndex++
|
||||||
}
|
}
|
||||||
sortedRels.Rel[currentIndex].Attachments = append(sortedRels.Rel[currentIndex].Attachments, attachment)
|
sortedRels.Releases[currentIndex].Attachments = append(sortedRels.Releases[currentIndex].Attachments, attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Makes URL's predictable
|
// Makes URL's predictable
|
||||||
for _, release := range rels {
|
for _, release := range sortedRels.Releases {
|
||||||
// If we have no Repo, we don't need to execute this loop
|
// If we have no Repo, we don't need to execute this loop
|
||||||
if release.Repo == nil {
|
if release.Repo == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ func TestAddReleaseAttachmentsRejectsDifferentRepo(t *testing.T) {
|
|||||||
assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure")
|
assert.Zero(t, attach.ReleaseID, "attachment should not be linked to release on failure")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAddReleaseAttachmentsRejectsIssueAttachment(t *testing.T) {
|
||||||
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
uuid := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"
|
||||||
|
err := AddReleaseAttachments(t.Context(), 1, []string{uuid})
|
||||||
|
assert.ErrorIs(t, err, util.ErrPermissionDenied)
|
||||||
|
|
||||||
|
attach, err := GetAttachmentByUUID(t.Context(), uuid)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Zero(t, attach.ReleaseID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) {
|
func TestAddReleaseAttachmentsAllowsLegacyMissingRepoID(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -494,10 +494,9 @@ func (repo *Repository) composeCommonMetas(ctx context.Context) map[string]strin
|
|||||||
repo.MustOwner(ctx)
|
repo.MustOwner(ctx)
|
||||||
if repo.Owner.IsOrganization() {
|
if repo.Owner.IsOrganization() {
|
||||||
teams := make([]string, 0, 5)
|
teams := make([]string, 0, 5)
|
||||||
_ = db.GetEngine(ctx).Table("team_repo").
|
_ = db.GetEngine(ctx).Select("team.lower_name").Table("team_repo").
|
||||||
Join("INNER", "team", "team.id = team_repo.team_id").
|
Join("INNER", "team", "team.id = team_repo.team_id").
|
||||||
Where("team_repo.repo_id = ?", repo.ID).
|
Where("team_repo.repo_id = ?", repo.ID).
|
||||||
Select("team.lower_name").
|
|
||||||
OrderBy("team.lower_name").
|
OrderBy("team.lower_name").
|
||||||
Find(&teams)
|
Find(&teams)
|
||||||
metas["teams"] = "," + strings.Join(teams, ",") + ","
|
metas["teams"] = "," + strings.Join(teams, ",") + ","
|
||||||
@@ -616,6 +615,15 @@ func ComposeHTTPSCloneURL(ctx context.Context, owner, repo string) string {
|
|||||||
|
|
||||||
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
|
// ComposeSSHCloneURL returns SSH clone URL based on the given owner and repository name.
|
||||||
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
|
func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) string {
|
||||||
|
return composeSSHCloneURL(doer, ownerName, repoName, setting.Repository.UseCompatSSHURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeSSHCloneURI is like ComposeSSHCloneURL but always returns the "ssh://" form, because "go get" rejects scp-style addresses
|
||||||
|
func ComposeSSHCloneURI(doer *user_model.User, ownerName, repoName string) string {
|
||||||
|
return composeSSHCloneURL(doer, ownerName, repoName, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func composeSSHCloneURL(doer *user_model.User, ownerName, repoName string, useURI bool) string {
|
||||||
sshUser := setting.SSH.User
|
sshUser := setting.SSH.User
|
||||||
sshDomain := setting.SSH.Domain
|
sshDomain := setting.SSH.Domain
|
||||||
|
|
||||||
@@ -642,7 +650,7 @@ func ComposeSSHCloneURL(doer *user_model.User, ownerName, repoName string) strin
|
|||||||
if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil {
|
if ip := net.ParseIP(sshHost); ip != nil && ip.To4() == nil {
|
||||||
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
|
sshHost = "[" + sshHost + "]" // for IPv6 address, wrap it with brackets
|
||||||
}
|
}
|
||||||
if setting.Repository.UseCompatSSHURI {
|
if useURI {
|
||||||
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
return fmt.Sprintf("ssh://%s@%s/%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
return fmt.Sprintf("%s@%s:%s/%s.git", sshUser, sshHost, url.PathEscape(ownerName), url.PathEscape(repoName))
|
||||||
|
|||||||
@@ -373,8 +373,7 @@ func SearchRepositoryCondition(opts SearchRepoOptions) builder.Cond {
|
|||||||
cond := builder.NewCond()
|
cond := builder.NewCond()
|
||||||
|
|
||||||
if opts.Private {
|
if opts.Private {
|
||||||
if opts.Actor != nil && !opts.Actor.IsAdmin && opts.Actor.ID != opts.OwnerID {
|
if opts.Actor != nil && !opts.Actor.IsAdmin {
|
||||||
// OK we're in the context of a User
|
|
||||||
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
cond = cond.And(AccessibleRepositoryCondition(opts.Actor, unit.TypeInvalid))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -762,7 +761,8 @@ func FindUserCodeAccessibleOwnerRepoIDs(ctx context.Context, ownerID int64, user
|
|||||||
func PublicRepoUnderPublicOwnerCond() builder.Cond {
|
func PublicRepoUnderPublicOwnerCond() builder.Cond {
|
||||||
return builder.And(
|
return builder.And(
|
||||||
builder.Eq{"`repository`.is_private": false},
|
builder.Eq{"`repository`.is_private": false},
|
||||||
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Eq{"visibility": structs.VisibleTypePublic})),
|
builder.Exists(builder.Select("1").From("`user` repo_owner").
|
||||||
|
Where(builder.Expr("repo_owner.id = `repository`.owner_id").And(builder.Eq{"repo_owner.visibility": structs.VisibleTypePublic}))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ func TestComposeSSHCloneURL(t *testing.T) {
|
|||||||
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
assert.Equal(t, "git@domain:user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
||||||
setting.Repository.UseCompatSSHURI = true
|
setting.Repository.UseCompatSSHURI = true
|
||||||
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURL(&user_model.User{Name: "doer"}, "user", "repo"))
|
||||||
|
setting.Repository.UseCompatSSHURI = false
|
||||||
|
assert.Equal(t, "ssh://git@domain/user/repo.git", ComposeSSHCloneURI(nil, "user", "repo"))
|
||||||
// test SSH_DOMAIN while use non-standard SSH port
|
// test SSH_DOMAIN while use non-standard SSH port
|
||||||
setting.SSH.Port = 123
|
setting.SSH.Port = 123
|
||||||
setting.Repository.UseCompatSSHURI = false
|
setting.Repository.UseCompatSSHURI = false
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func IsErrRepoAlreadyExist(err error) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (err ErrRepoAlreadyExist) Error() string {
|
func (err ErrRepoAlreadyExist) Error() string {
|
||||||
return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name)
|
return fmt.Sprintf("repository already exists [owner: %s, repo: %s]", err.Uname, err.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (err ErrRepoAlreadyExist) Unwrap() error {
|
func (err ErrRepoAlreadyExist) Unwrap() error {
|
||||||
|
|||||||
+32
-8
@@ -20,9 +20,10 @@ type WatchMode int8
|
|||||||
const (
|
const (
|
||||||
WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned
|
WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned
|
||||||
|
|
||||||
WatchModeNormal // 1 proactively watching (all or custom)
|
WatchModeNormal // 1 proactively watching (all, or custom before WatchModeCustom)
|
||||||
WatchModeDont // 2 ignore the repo
|
WatchModeDont // 2 ignore the repo
|
||||||
WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges)
|
WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges)
|
||||||
|
WatchModeCustom // 4 watching the events picked in the custom watch options
|
||||||
)
|
)
|
||||||
|
|
||||||
// WatchType is the `watch` column gating one kind of notification
|
// WatchType is the `watch` column gating one kind of notification
|
||||||
@@ -38,7 +39,7 @@ const (
|
|||||||
type Watch struct {
|
type Watch struct {
|
||||||
ID int64 `xorm:"pk autoincr"`
|
ID int64 `xorm:"pk autoincr"`
|
||||||
UserID int64 `xorm:"UNIQUE(watch)"`
|
UserID int64 `xorm:"UNIQUE(watch)"`
|
||||||
RepoID int64 `xorm:"UNIQUE(watch)"`
|
RepoID int64 `xorm:"UNIQUE(watch) INDEX"`
|
||||||
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
||||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||||
@@ -79,7 +80,7 @@ func (w *Watch) IsWatching() bool {
|
|||||||
|
|
||||||
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
|
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
|
||||||
func (w *Watch) IsWatchingAll() bool {
|
func (w *Watch) IsWatchingAll() bool {
|
||||||
return w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
|
return w.Mode != WatchModeCustom && w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watch) IsWatchingAny() bool {
|
func (w *Watch) IsWatchingAny() bool {
|
||||||
@@ -111,6 +112,9 @@ func IsWatchingRepo(ctx context.Context, userID, repoID int64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
|
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
|
||||||
|
if watch.UserID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if watch.Mode == mode {
|
if watch.Mode == mode {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -166,6 +170,9 @@ func WatchRepoAuto(ctx context.Context, doer *user_model.User, repo *Repository,
|
|||||||
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
|
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
|
||||||
return user_model.ErrBlockedUser
|
return user_model.ErrBlockedUser
|
||||||
}
|
}
|
||||||
|
if watch.Mode == WatchModeCustom { // keep the events the user picked
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return watchRepoByMode(ctx, watch, WatchModeNormal)
|
return watchRepoByMode(ctx, watch, WatchModeNormal)
|
||||||
}
|
}
|
||||||
@@ -189,7 +196,7 @@ func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if opts.Mode == WatchModeNormal {
|
if opts.Mode == WatchModeNormal || opts.Mode == WatchModeCustom {
|
||||||
_, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID).
|
_, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID).
|
||||||
Cols("include_pull_requests", "include_issues", "include_releases").
|
Cols("include_pull_requests", "include_issues", "include_releases").
|
||||||
Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases})
|
Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases})
|
||||||
@@ -247,6 +254,16 @@ func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) {
|
|||||||
// but avoids joining with `user` for performance reasons
|
// but avoids joining with `user` for performance reasons
|
||||||
// User permissions must be verified elsewhere if required
|
// User permissions must be verified elsewhere if required
|
||||||
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
|
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
|
||||||
|
return getRepoWatchersIDs(ctx, repoID, watchType, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoCustomWatchersIDs is GetRepoWatchersIDs limited to custom watches, which unlike
|
||||||
|
// watching all activity are never set up automatically (e.g. by AutoWatchNewRepos)
|
||||||
|
func GetRepoCustomWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
|
||||||
|
return getRepoWatchersIDs(ctx, repoID, watchType, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType, customOnly bool) ([]int64, error) {
|
||||||
ids := make([]int64, 0, 64)
|
ids := make([]int64, 0, 64)
|
||||||
var watchColName string
|
var watchColName string
|
||||||
switch watchType {
|
switch watchType {
|
||||||
@@ -259,12 +276,19 @@ func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType)
|
|||||||
default:
|
default:
|
||||||
panic("invalid WatchType")
|
panic("invalid WatchType")
|
||||||
}
|
}
|
||||||
return ids, db.GetEngine(ctx).Table("watch").
|
sess := db.GetEngine(ctx).Table("watch").
|
||||||
Where("watch.repo_id=?", repoID).
|
Where("watch.repo_id=?", repoID).
|
||||||
And("watch.mode<>?", WatchModeDont).
|
And("watch.mode<>?", WatchModeDont).
|
||||||
And(builder.Eq{watchColName: true}).
|
And(builder.Eq{watchColName: true})
|
||||||
Select("user_id").
|
if customOnly { // matches SelectedMode() == "custom"
|
||||||
Find(&ids)
|
sess = sess.And(builder.Or(
|
||||||
|
builder.Eq{"watch.mode": WatchModeCustom},
|
||||||
|
builder.Eq{"watch.include_pull_requests": false},
|
||||||
|
builder.Eq{"watch.include_issues": false},
|
||||||
|
builder.Eq{"watch.include_releases": false},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return ids, sess.Select("user_id").Find(&ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRepoWatchers returns range of users watching given repository.
|
// GetRepoWatchers returns range of users watching given repository.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestGetWatchers(t *testing.T) {
|
|||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
|
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
// One watchers are inactive, thus minus 1
|
// One watcher is inactive, thus minus 1
|
||||||
assert.Len(t, watches, repo.NumWatches-1)
|
assert.Len(t, watches, repo.NumWatches-1)
|
||||||
for _, watch := range watches {
|
for _, watch := range watches {
|
||||||
assert.Equal(t, repo.ID, watch.RepoID)
|
assert.Equal(t, repo.ID, watch.RepoID)
|
||||||
@@ -90,24 +90,30 @@ func TestWatchIfAuto(t *testing.T) {
|
|||||||
|
|
||||||
setting.Service.AutoWatchOnChanges = true
|
setting.Service.AutoWatchOnChanges = true
|
||||||
|
|
||||||
// Must not add watch
|
// Must not add watch (user 8 has "don't watch" set)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 8, 1, true))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount)
|
assert.Len(t, watchers, prevCount)
|
||||||
|
|
||||||
// Should not add watch
|
// Should not add watch (read operation)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, false))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount)
|
assert.Len(t, watchers, prevCount)
|
||||||
|
|
||||||
// Should add watch
|
// Should add watch (write operation)
|
||||||
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), 12, 1, true))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Len(t, watchers, prevCount+1)
|
assert.Len(t, watchers, prevCount+1)
|
||||||
|
|
||||||
|
// Should not add watch (system user should not be able to watch)
|
||||||
|
assert.NoError(t, repo_model.WatchIfAuto(t.Context(), user_model.ActionsUserID, 1, true))
|
||||||
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, watchers, prevCount+1)
|
||||||
|
|
||||||
// Should remove watch, inhibit from adding auto
|
// Should remove watch, inhibit from adding auto
|
||||||
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
|
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
|
||||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
|
||||||
@@ -145,7 +151,7 @@ func TestWatchOptions(t *testing.T) {
|
|||||||
// repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled
|
// repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled
|
||||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true}))
|
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true})) // custom watches were stored like this before WatchModeCustom
|
||||||
|
|
||||||
for watchType, expected := range map[repo_model.WatchType][]int64{
|
for watchType, expected := range map[repo_model.WatchType][]int64{
|
||||||
repo_model.WatchPullRequests: {1, 4, 9, 11},
|
repo_model.WatchPullRequests: {1, 4, 9, 11},
|
||||||
@@ -157,6 +163,20 @@ func TestWatchOptions(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, expected, ids, watchType)
|
assert.ElementsMatch(t, expected, ids, watchType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a custom watch picking every event differs from all activity, which can be set up automatically
|
||||||
|
customUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||||
|
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), customUser, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeCustom, WatchPullRequests: true, WatchIssues: true, WatchReleases: true}))
|
||||||
|
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), customUser, repo, true)) // must keep the custom watch
|
||||||
|
for watchType, expected := range map[repo_model.WatchType][]int64{
|
||||||
|
repo_model.WatchPullRequests: {1, 4},
|
||||||
|
repo_model.WatchIssues: {4},
|
||||||
|
repo_model.WatchReleases: {4},
|
||||||
|
} {
|
||||||
|
ids, err := repo_model.GetRepoCustomWatchersIDs(t.Context(), repo.ID, watchType)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, expected, ids, watchType)
|
||||||
|
}
|
||||||
|
|
||||||
// the options of one user must not show up for another
|
// the options of one user must not show up for another
|
||||||
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
|
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -176,5 +196,6 @@ func TestWatchSelectedMode(t *testing.T) {
|
|||||||
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
|
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
|
||||||
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
|
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
|
||||||
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode())
|
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode())
|
||||||
|
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeCustom, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
|
||||||
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
|
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -103,6 +104,10 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
|
|||||||
require.NoError(t, err, "creating fixture %s", fixturePath)
|
require.NoError(t, err, "creating fixture %s", fixturePath)
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_, err = fmt.Fprintf(out, "HTTP/1.1 %d %s\n", resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||||
|
require.NoError(t, err, "writing fixture header for %s", liveURL)
|
||||||
|
}
|
||||||
for _, name := range slices.Sorted(maps.Keys(resp.Header)) {
|
for _, name := range slices.Sorted(maps.Keys(resp.Header)) {
|
||||||
if slices.Contains(ignoredHeaders, strings.ToLower(name)) {
|
if slices.Contains(ignoredHeaders, strings.ToLower(name)) {
|
||||||
continue
|
continue
|
||||||
@@ -125,14 +130,21 @@ func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveM
|
|||||||
|
|
||||||
replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL)
|
replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL)
|
||||||
headers, body, _ := strings.Cut(replayed, "\n\n")
|
headers, body, _ := strings.Cut(replayed, "\n\n")
|
||||||
|
code := http.StatusOK
|
||||||
for line := range strings.SplitSeq(headers, "\n") {
|
for line := range strings.SplitSeq(headers, "\n") {
|
||||||
|
if strings.HasPrefix(line, "HTTP/") {
|
||||||
|
parts := strings.SplitN(line, " ", 3)
|
||||||
|
require.Len(t, parts, 3)
|
||||||
|
code, _ = strconv.Atoi(parts[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
name, value, ok := strings.Cut(line, ": ")
|
name, value, ok := strings.Cut(line, ": ")
|
||||||
if !ok || strings.EqualFold(name, "Content-Length") {
|
if !ok || strings.EqualFold(name, "Content-Length") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
w.Header().Set(name, value)
|
w.Header().Set(name, value)
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(code)
|
||||||
_, err = w.Write([]byte(body))
|
_, err = w.Write([]byte(body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -151,16 +151,28 @@ func TestListEmails(t *testing.T) {
|
|||||||
|
|
||||||
func TestEmailAddressValidate(t *testing.T) {
|
func TestEmailAddressValidate(t *testing.T) {
|
||||||
cases := map[string]bool{
|
cases := map[string]bool{
|
||||||
"": false,
|
"": false,
|
||||||
"root@localhost": true,
|
"@a": false,
|
||||||
"user@[192.168.1.2]": true,
|
|
||||||
"@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
|
||||||
"abc@gmail.com": true,
|
"root@local_host": true,
|
||||||
"abc@gmail.com\n": false,
|
"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,
|
"Foo <foo@bar.com>": false,
|
||||||
"abc@gmail.com (x)": false,
|
"abc@gmail.com (x)": false,
|
||||||
"jürgen@example.com": false,
|
"jürgen@example.com": false, // utf8 address is not supported yet
|
||||||
"a@foo_bar.com": false,
|
|
||||||
}
|
}
|
||||||
for tc, isValid := range cases {
|
for tc, isValid := range cases {
|
||||||
t.Run(tc, func(t *testing.T) {
|
t.Run(tc, func(t *testing.T) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
"gitea.dev/modules/timeutil"
|
"gitea.dev/modules/timeutil"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
"xorm.io/builder"
|
"xorm.io/builder"
|
||||||
)
|
)
|
||||||
@@ -32,6 +33,9 @@ func IsFollowing(ctx context.Context, userID, followID int64) bool {
|
|||||||
|
|
||||||
// FollowUser marks someone be another's follower.
|
// FollowUser marks someone be another's follower.
|
||||||
func FollowUser(ctx context.Context, user, follow *User) (err error) {
|
func FollowUser(ctx context.Context, user, follow *User) (err error) {
|
||||||
|
if !follow.IsFollowable() {
|
||||||
|
return util.NewInvalidArgumentErrorf("user %d can not be followed", follow.ID)
|
||||||
|
}
|
||||||
if user.ID == follow.ID || IsFollowing(ctx, user.ID, follow.ID) {
|
if user.ID == follow.ID || IsFollowing(ctx, user.ID, follow.ID) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-6
@@ -68,13 +68,20 @@ const (
|
|||||||
UserTypeRemoteUser // 5
|
UserTypeRemoteUser // 5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// AdminListedUserTypes HINT: USER-TYPE-GLOSSARY: administrators list every user type except organizations
|
||||||
|
var AdminListedUserTypes = []UserType{UserTypeIndividual, UserTypeUserReserved, UserTypeBot, UserTypeRemoteUser}
|
||||||
|
|
||||||
// DisplayName returns the English name of the user type for logs and the CLI, the UI translates "concept_user_*" instead
|
// DisplayName returns the English name of the user type for logs and the CLI, the UI translates "concept_user_*" instead
|
||||||
func (t UserType) DisplayName() string {
|
func (t UserType) DisplayName() string {
|
||||||
switch t {
|
switch t {
|
||||||
case UserTypeOrganization, UserTypeOrganizationReserved:
|
case UserTypeOrganization, UserTypeOrganizationReserved:
|
||||||
return "Organization"
|
return "Organization"
|
||||||
|
case UserTypeUserReserved:
|
||||||
|
return "Reserved"
|
||||||
case UserTypeBot:
|
case UserTypeBot:
|
||||||
return "Bot"
|
return "Bot"
|
||||||
|
case UserTypeRemoteUser:
|
||||||
|
return "Remote"
|
||||||
default:
|
default:
|
||||||
return "User"
|
return "User"
|
||||||
}
|
}
|
||||||
@@ -250,10 +257,10 @@ func (u *User) GetEmail() string {
|
|||||||
return u.Email
|
return u.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllUsers returns a slice of all individual users found in DB.
|
// GetAllUsersForAdminListing returns a slice of all users of AdminListedUserTypes found in DB.
|
||||||
func GetAllUsers(ctx context.Context) ([]*User, error) {
|
func GetAllUsersForAdminListing(ctx context.Context) (users []*User, err error) {
|
||||||
users := make([]*User, 0)
|
err = db.GetEngine(ctx).OrderBy("id").In("type", AdminListedUserTypes).Find(&users)
|
||||||
return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users)
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsLocal returns true if user login type is LoginPlain.
|
// IsLocal returns true if user login type is LoginPlain.
|
||||||
@@ -440,6 +447,18 @@ func (u *User) IsIndividual() bool {
|
|||||||
return u.ID > 0 && u.Type == UserTypeIndividual
|
return u.ID > 0 && u.Type == UserTypeIndividual
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsFollowable returns whether other users can follow this user (individuals, bots and organizations)
|
||||||
|
func (u *User) IsFollowable() bool {
|
||||||
|
if u.ID <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch u.Type {
|
||||||
|
case UserTypeIndividual, UserTypeBot, UserTypeOrganization:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// IsTypeBot returns whether the user is of type bot
|
// IsTypeBot returns whether the user is of type bot
|
||||||
func (u *User) IsTypeBot() bool {
|
func (u *User) IsTypeBot() bool {
|
||||||
return u.Type == UserTypeBot
|
return u.Type == UserTypeBot
|
||||||
@@ -1021,7 +1040,8 @@ func GetInactiveUsers(ctx context.Context, olderThan time.Duration) ([]*User, er
|
|||||||
Find(&users)
|
Find(&users)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByID returns the user object by given ID if exists.
|
// GetUserByID returns the user object by given ID if exists. TODO: for most cases, this function should be avoided.
|
||||||
|
// Use correct GetXxxUser functions instead to handle user types & states correctly.
|
||||||
func GetUserByID(ctx context.Context, id int64) (*User, error) {
|
func GetUserByID(ctx context.Context, id int64) (*User, error) {
|
||||||
u := new(User)
|
u := new(User)
|
||||||
has, err := db.GetEngine(ctx).ID(id).Get(u)
|
has, err := db.GetEngine(ctx).ID(id).Get(u)
|
||||||
@@ -1033,7 +1053,8 @@ func GetUserByID(ctx context.Context, id int64) (*User, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByIDs returns the user objects by given IDs if exists.
|
// GetUserByIDs returns the user objects by given IDs if exists. TODO: for most cases, this function should be avoided.
|
||||||
|
// Use correct GetXxxUser functions instead to handle user types & states correctly.
|
||||||
func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -1046,6 +1067,13 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
|||||||
return users, err
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HINT: USER-TYPE-GLOSSARY: Glossaries for XxxUser:
|
||||||
|
// * Possible: The user may or may not exist in the database, but we can still return a valid user object (e.g. Ghost user) for the given ID.
|
||||||
|
// * Individual: The user is a real person, not an organization or a bot.
|
||||||
|
// * Doer: The user is the one performing an action
|
||||||
|
// * Valid: The user is an active user and not prohibited from signing in
|
||||||
|
// * System: Negative ID (e.g. Ghost, Actions, DeployKey, etc.) and not a real person, Ghost is not included if there is no "Possible" keyword
|
||||||
|
|
||||||
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
|
// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user
|
||||||
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
|
func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) {
|
||||||
if id < 0 {
|
if id < 0 {
|
||||||
@@ -1082,6 +1110,23 @@ func GetPossibleUserByIDs(ctx context.Context, ids []int64) ([]*User, error) {
|
|||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetValidDoerUserByID(ctx context.Context, id int64) (*User, error) {
|
||||||
|
u, err := GetUserByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !u.IsIndividual() && !u.IsTypeBot() {
|
||||||
|
return nil, util.NewNotExistErrorf("user %d is not a doer", id)
|
||||||
|
}
|
||||||
|
if !u.IsActive {
|
||||||
|
return nil, util.NewPermissionDeniedErrorf("user %d is inactive", id)
|
||||||
|
}
|
||||||
|
if u.ProhibitLogin {
|
||||||
|
return nil, util.NewPermissionDeniedErrorf("user %d is prohibited from login", id)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
func getUserByNameWithTypes(ctx context.Context, name string, types ...UserType) (*User, error) {
|
func getUserByNameWithTypes(ctx context.Context, name string, types ...UserType) (*User, error) {
|
||||||
u := &User{}
|
u := &User{}
|
||||||
sess := db.GetEngine(ctx).Where(builder.Eq{"lower_name": strings.ToLower(name)})
|
sess := db.GetEngine(ctx).Where(builder.Eq{"lower_name": strings.ToLower(name)})
|
||||||
@@ -1201,6 +1246,9 @@ func GetUsersByEmails(ctx context.Context, emails []string) (*EmailUserMap, erro
|
|||||||
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
|
noReplyAddressSuffix := "@" + strings.ToLower(setting.Service.NoReplyAddress)
|
||||||
for _, email := range emails {
|
for _, email := range emails {
|
||||||
emailLower := strings.ToLower(email)
|
emailLower := strings.ToLower(email)
|
||||||
|
if emailLower == "" {
|
||||||
|
continue // a commit without an author email names no user
|
||||||
|
}
|
||||||
needCheckEmails.Add(emailLower)
|
needCheckEmails.Add(emailLower)
|
||||||
if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
|
if localPart, ok := strings.CutSuffix(emailLower, noReplyAddressSuffix); ok {
|
||||||
name, id := parseLocalPartToNameID(localPart)
|
name, id := parseLocalPartToNameID(localPart)
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ func TestUserEmails(t *testing.T) {
|
|||||||
setting.Service.NoReplyAddress = "example.com"
|
setting.Service.NoReplyAddress = "example.com"
|
||||||
testGetUserByEmail(t, "user1-2@example.COM", 1)
|
testGetUserByEmail(t, "user1-2@example.COM", 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyEmail", func(t *testing.T) {
|
||||||
|
// A user whose email address is empty must not own commits that have no author email.
|
||||||
|
require.NoError(t, db.Insert(t.Context(), &user_model.EmailAddress{UID: 2, Email: "", LowerEmail: "", IsActivated: true}))
|
||||||
|
testGetUserByEmail(t, "", 0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
t.Run("GetUserByEmail", func(t *testing.T) {
|
t.Run("GetUserByEmail", func(t *testing.T) {
|
||||||
testGetUserByEmail := func(t *testing.T, email string, uid int64) {
|
testGetUserByEmail := func(t *testing.T, email string, uid int64) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
actions_model "gitea.dev/models/actions"
|
actions_model "gitea.dev/models/actions"
|
||||||
"gitea.dev/models/db"
|
"gitea.dev/models/db"
|
||||||
git_model "gitea.dev/models/git"
|
git_model "gitea.dev/models/git"
|
||||||
repo_model "gitea.dev/models/repo"
|
|
||||||
"gitea.dev/modules/log"
|
"gitea.dev/modules/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,19 +35,11 @@ func GetCommitActionsStatusMap(ctx context.Context, statuses []*git_model.Commit
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
statusByJobID := make(map[int64]*git_model.CommitStatus)
|
statusByJobID := make(map[int64]*git_model.CommitStatus)
|
||||||
repoByID := make(map[int64]*repo_model.Repository)
|
|
||||||
for _, status := range statuses {
|
for _, status := range statuses {
|
||||||
if status == nil || status.TargetURL == "" {
|
if status == nil || status.TargetURL == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if status.Repo == nil {
|
if _, jobID, ok := status.ParseGiteaActionsTargetURL(ctx); ok {
|
||||||
status.Repo = repoByID[status.RepoID]
|
|
||||||
}
|
|
||||||
// ParseGiteaActionsTargetURL lazy-loads status.Repo on miss; cache the
|
|
||||||
// outcome so later entries with the same RepoID skip that load.
|
|
||||||
_, jobID, ok := status.ParseGiteaActionsTargetURL(ctx)
|
|
||||||
repoByID[status.RepoID] = status.Repo
|
|
||||||
if ok {
|
|
||||||
statusByJobID[jobID] = status
|
statusByJobID[jobID] = status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package jobparser
|
package jobparser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -65,44 +64,31 @@ func (w *SingleWorkflow) SetJob(id string, job *Job) error {
|
|||||||
m := map[string]*Job{
|
m := map[string]*Job{
|
||||||
id: job,
|
id: job,
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
out, err := marshalYAML(m)
|
||||||
encoder := yaml.NewEncoder(&buf)
|
if err != nil {
|
||||||
encoder.SetIndent(2)
|
|
||||||
if err := encoder.Encode(m); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
encoder.Close()
|
|
||||||
|
|
||||||
node := yaml.Node{}
|
node := yaml.Node{}
|
||||||
if err := yaml.Unmarshal(buf.Bytes(), &node); err != nil {
|
if err := yaml.Unmarshal(out, &node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
|
if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode {
|
||||||
return fmt.Errorf("can not set job: %s", buf.String())
|
return fmt.Errorf("can not set job: %s", out)
|
||||||
}
|
}
|
||||||
w.RawJobs = *node.Content[0]
|
w.RawJobs = *node.Content[0]
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *SingleWorkflow) Marshal() ([]byte, error) {
|
func (w *SingleWorkflow) Marshal() ([]byte, error) {
|
||||||
// Encode with the same indentation SetJob uses (2). yaml.Marshal's default
|
|
||||||
// indentation (4) makes the encoder emit multi-line block scalars (e.g. a
|
|
||||||
// `run:` step that begins with blank lines) with a wrong explicit indentation
|
|
||||||
// indicator (`run: |4`) that then fails to re-parse, which silently strands
|
|
||||||
// the job during concurrency evaluation. Keeping both encoders at indent 2
|
|
||||||
// makes the serialized single workflow round-trip.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
enc := yaml.NewEncoder(&buf)
|
|
||||||
enc.SetIndent(2)
|
|
||||||
payload := *w
|
payload := *w
|
||||||
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
|
payload.RunName = "" // already interpolated into the run title, a runner would parse it as a template again
|
||||||
if err := enc.Encode(&payload); err != nil {
|
return marshalYAML(&payload)
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
if err := enc.Close(); err != nil {
|
// marshalYAML never wraps lines, go-yaml wraps more-indented lines of folded scalars until https://github.com/yaml/go-yaml/pull/441 is released.
|
||||||
return nil, err
|
func marshalYAML(v any) ([]byte, error) {
|
||||||
}
|
return yaml.Dump(v, yaml.WithV3Defaults(), yaml.WithIndent(2), yaml.WithLineWidth(-1))
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
@@ -188,29 +174,18 @@ func (j *Job) DisplayName() string {
|
|||||||
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
|
return util.EllipsisDisplayString(unescapeExpressions(j.Name), 255)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BlockSafeString works around https://github.com/yaml/go-yaml/issues/399, quoting a value whose
|
|
||||||
// leading newline would cost a literal block scalar its indentation indicator.
|
|
||||||
type BlockSafeString string
|
|
||||||
|
|
||||||
func (s BlockSafeString) MarshalYAML() (any, error) {
|
|
||||||
if !strings.HasPrefix(string(s), "\n") {
|
|
||||||
return string(s), nil
|
|
||||||
}
|
|
||||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle, Value: string(s)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step struct {
|
type Step struct {
|
||||||
ID string `yaml:"id,omitempty"`
|
ID string `yaml:"id,omitempty"`
|
||||||
If yaml.Node `yaml:"if,omitempty"`
|
If yaml.Node `yaml:"if,omitempty"`
|
||||||
Name BlockSafeString `yaml:"name,omitempty"`
|
Name string `yaml:"name,omitempty"`
|
||||||
Uses string `yaml:"uses,omitempty"`
|
Uses string `yaml:"uses,omitempty"`
|
||||||
Run BlockSafeString `yaml:"run,omitempty"`
|
Run string `yaml:"run,omitempty"`
|
||||||
WorkingDirectory string `yaml:"working-directory,omitempty"`
|
WorkingDirectory string `yaml:"working-directory,omitempty"`
|
||||||
Shell string `yaml:"shell,omitempty"`
|
Shell string `yaml:"shell,omitempty"`
|
||||||
Env yaml.Node `yaml:"env,omitempty"`
|
Env yaml.Node `yaml:"env,omitempty"`
|
||||||
With yaml.Node `yaml:"with,omitempty"`
|
With yaml.Node `yaml:"with,omitempty"`
|
||||||
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
|
RawContinueOnError yaml.Node `yaml:"continue-on-error,omitempty"` // raw: the runner evaluates it with the steps context
|
||||||
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
|
TimeoutMinutes string `yaml:"timeout-minutes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalYAML canonicalizes booleans like continue-on-error
|
// UnmarshalYAML canonicalizes booleans like continue-on-error
|
||||||
@@ -232,9 +207,9 @@ func (s *Step) String() string {
|
|||||||
}
|
}
|
||||||
return (&model.Step{
|
return (&model.Step{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
Name: string(s.Name),
|
Name: s.Name,
|
||||||
Uses: s.Uses,
|
Uses: s.Uses,
|
||||||
Run: string(s.Run),
|
Run: s.Run,
|
||||||
}).String()
|
}).String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
_, origJob := sws[0].Job()
|
_, origJob := sws[0].Job()
|
||||||
require.Len(t, origJob.Steps, 1)
|
require.Len(t, origJob.Steps, 1)
|
||||||
const wantRun = "\n\necho start\necho done\n"
|
const wantRun = "\n\necho start\necho done\n"
|
||||||
require.Equal(t, wantRun, string(origJob.Steps[0].Run))
|
require.Equal(t, wantRun, origJob.Steps[0].Run)
|
||||||
|
|
||||||
payload, err := sws[0].Marshal()
|
payload, err := sws[0].Marshal()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
// the round-trip must preserve the run block byte-for-byte
|
// the round-trip must preserve the run block byte-for-byte
|
||||||
_, gotJob := roundTripped[0].Job()
|
_, gotJob := roundTripped[0].Job()
|
||||||
require.Len(t, gotJob.Steps, 1)
|
require.Len(t, gotJob.Steps, 1)
|
||||||
require.Equal(t, wantRun, string(gotJob.Steps[0].Run), "round-trip must preserve run content; got payload:\n%s", payload)
|
require.Equal(t, wantRun, gotJob.Steps[0].Run, "round-trip must preserve run content; got payload:\n%s", payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node.
|
// Typing a step's continue-on-error as a bool used to reject the whole `jobs:` node.
|
||||||
@@ -104,3 +104,37 @@ jobs:
|
|||||||
require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload)
|
require.Equal(t, w, steps[i].RawContinueOnError, "step %d, payload:\n%s", i, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folded (`>`) scalars in with/env used to gain blank lines and have spaces turned into newlines
|
||||||
|
// on every Marshal -> Parse cycle.
|
||||||
|
func TestSingleWorkflowRoundTripFoldedScalar(t *testing.T) {
|
||||||
|
const wf = `name: demo
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: ./.github/actions/build
|
||||||
|
with:
|
||||||
|
cfg: >
|
||||||
|
begin
|
||||||
|
first "indented" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,
|
||||||
|
second "indented" line
|
||||||
|
end
|
||||||
|
`
|
||||||
|
const want = "begin\n first \"indented\" line with path dir/Installer Scripts/some long path/another long path/some file name.xzy,\n second \"indented\" line\nend\n"
|
||||||
|
sws, err := Parse([]byte(wf))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, sws, 1)
|
||||||
|
for range 3 {
|
||||||
|
payload, err := sws[0].Marshal()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sws, err = Parse(payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, sws, 1)
|
||||||
|
}
|
||||||
|
_, job := sws[0].Job()
|
||||||
|
var with map[string]string
|
||||||
|
require.NoError(t, job.Steps[0].With.Decode(&with))
|
||||||
|
require.Equal(t, want, with["cfg"])
|
||||||
|
}
|
||||||
@@ -7,5 +7,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- id: extract_tag
|
- id: extract_tag
|
||||||
name: "\nExtract tag for variant"
|
name: |-
|
||||||
run: "\necho Test\n"
|
|
||||||
|
Extract tag for variant
|
||||||
|
run: |
|
||||||
|
|
||||||
|
echo Test
|
||||||
@@ -532,7 +532,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
if matchActivityType(val, actions...) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -575,7 +575,7 @@ func matchPullRequestEvent(ctx context.Context, gitRepo *git.Repository, commit
|
|||||||
}
|
}
|
||||||
log.Trace("matching pull_request %s with %v", action, vals)
|
log.Trace("matching pull_request %s with %v", action, vals)
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
if matchActivityType(val, string(action)) {
|
||||||
activityTypeMatched = true
|
activityTypeMatched = true
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
@@ -678,7 +678,7 @@ func matchIssueCommentEvent(issueCommentPayload *api.IssueCommentPayload, evt *j
|
|||||||
// NONE
|
// NONE
|
||||||
|
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(string(issueCommentPayload.Action)) {
|
if matchActivityType(val, string(issueCommentPayload.Action)) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -718,7 +718,7 @@ func matchPullRequestReviewEvent(prPayload *api.PullRequestPayload, evt *jobpars
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
if matchActivityType(val, actions...) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -758,7 +758,7 @@ func matchPullRequestReviewCommentEvent(prPayload *api.PullRequestPayload, evt *
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if slices.ContainsFunc(actions, glob.MustCompile(val, '/').Match) {
|
if matchActivityType(val, actions...) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -795,7 +795,7 @@ func matchReleaseEvent(payload *api.ReleasePayload, evt *jobparser.Event) bool {
|
|||||||
action = "edited"
|
action = "edited"
|
||||||
}
|
}
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
if matchActivityType(val, string(action)) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -832,7 +832,7 @@ func matchPackageEvent(payload *api.PackagePayload, evt *jobparser.Event) bool {
|
|||||||
action = "published"
|
action = "published"
|
||||||
}
|
}
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(string(action)) {
|
if matchActivityType(val, string(action)) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -857,7 +857,7 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
|
|||||||
case "types":
|
case "types":
|
||||||
action := payload.Action
|
action := payload.Action
|
||||||
for _, val := range vals {
|
for _, val := range vals {
|
||||||
if glob.MustCompile(val, '/').Match(action) {
|
if matchActivityType(val, action) {
|
||||||
matchTimes++
|
matchTimes++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -893,3 +893,13 @@ func matchWorkflowRunEvent(payload *api.WorkflowRunPayload, evt *jobparser.Event
|
|||||||
}
|
}
|
||||||
return matchTimes == len(evt.Acts())
|
return matchTimes == len(evt.Acts())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchActivityType treats an invalid user-provided pattern as no match instead of panicking
|
||||||
|
func matchActivityType(pattern string, actions ...string) bool {
|
||||||
|
g, err := glob.Compile(pattern, '/')
|
||||||
|
if err != nil {
|
||||||
|
log.Debug("invalid activity type pattern %q: %v", pattern, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(actions, g.Match)
|
||||||
|
}
|
||||||
@@ -205,6 +205,13 @@ func TestDetectMatched(t *testing.T) {
|
|||||||
yamlOn: "on:\n release:\n types: [published]",
|
yamlOn: "on:\n release:\n types: [published]",
|
||||||
expected: detectMatched,
|
expected: detectMatched,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
desc: "HookEventRelease(release) doesn't match an invalid activity type pattern",
|
||||||
|
triggedEvent: webhook_module.HookEventRelease,
|
||||||
|
payload: &api.ReleasePayload{Action: api.HookReleasePublished},
|
||||||
|
yamlOn: "on:\n release:\n types: [\"[\"]",
|
||||||
|
expected: detectNotApplicable,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
|
desc: "HookEventPackage(package) `created` action doesn't match GithubEventRegistryPackage(registry_package) with `updated` activity type",
|
||||||
triggedEvent: webhook_module.HookEventPackage,
|
triggedEvent: webhook_module.HookEventPackage,
|
||||||
|
|||||||
@@ -102,11 +102,11 @@ func NewEmbeddedFS(data []byte) fs.ReadDirFS {
|
|||||||
efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)}
|
efs := &embeddedFS{data: data, files: make(map[string]*embeddedFileInfo)}
|
||||||
efs.meta = sync.OnceValue(func() *EmbeddedMeta {
|
efs.meta = sync.OnceValue(func() *EmbeddedMeta {
|
||||||
var meta EmbeddedMeta
|
var meta EmbeddedMeta
|
||||||
p := bytes.LastIndexByte(data, '\n')
|
_, metaJSON, ok := bytes.CutLast(data, []byte{'\n'})
|
||||||
if p < 0 {
|
if !ok {
|
||||||
return &meta
|
return &meta
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data[p+1:], &meta); err != nil {
|
if err := json.Unmarshal(metaJSON, &meta); err != nil {
|
||||||
panic("embedded file is not valid")
|
panic("embedded file is not valid")
|
||||||
}
|
}
|
||||||
return &meta
|
return &meta
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ func Init() {
|
|||||||
RPID: setting.AppDomain,
|
RPID: setting.AppDomain,
|
||||||
RPOrigins: []string{appURL},
|
RPOrigins: []string{appURL},
|
||||||
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
||||||
|
// WebKit (Safari on iOS and macOS) returns an unsolicited "appid" client
|
||||||
|
// extension output even when the relying party did not request it. Since the
|
||||||
|
// webauthn library upgrade to v0.18, the default policy fails such ceremonies
|
||||||
|
// ('Client returned the appid extension output which was not requested'), breaking
|
||||||
|
// every passkey and security-key login from Safari on iOS. Ignore unsolicited
|
||||||
|
// extension outputs, the same policy other relying parties adopted for the
|
||||||
|
// same WebKit behavior.
|
||||||
|
ExtensionsUnsolicitedOutputPolicy: protocol.UnsolicitedOutputPolicyIgnore,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.dev/modules/setting"
|
"gitea.dev/modules/setting"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,4 +23,5 @@ func TestInit(t *testing.T) {
|
|||||||
assert.Equal(t, setting.AppDomain, WebAuthn.Config.RPID)
|
assert.Equal(t, setting.AppDomain, WebAuthn.Config.RPID)
|
||||||
assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName)
|
assert.Equal(t, setting.AppName, WebAuthn.Config.RPDisplayName)
|
||||||
assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins)
|
assert.Equal(t, rpOrigin, WebAuthn.Config.RPOrigins)
|
||||||
|
assert.Equal(t, protocol.UnsolicitedOutputPolicyIgnore, WebAuthn.Config.ExtensionsUnsolicitedOutputPolicy)
|
||||||
}
|
}
|
||||||
@@ -8,5 +8,6 @@ const (
|
|||||||
EmailAvatarLink = "email_avatar_link"
|
EmailAvatarLink = "email_avatar_link"
|
||||||
UserEmailAddresses = "user_email_addresses"
|
UserEmailAddresses = "user_email_addresses"
|
||||||
GPGKeyWithSubKeys = "gpg_key_with_subkeys"
|
GPGKeyWithSubKeys = "gpg_key_with_subkeys"
|
||||||
|
Repository = "repository"
|
||||||
RepoUserPermission = "repo_user_permission"
|
RepoUserPermission = "repo_user_permission"
|
||||||
)
|
)
|
||||||
@@ -52,7 +52,7 @@ func Lookup(label string) (e encoding.Encoding, name string) {
|
|||||||
|
|
||||||
type ConvertOpts struct {
|
type ConvertOpts struct {
|
||||||
KeepBOM bool
|
KeepBOM bool
|
||||||
ErrorReplacement []byte
|
ErrorReplacement []byte // leave it empty to remove the invalid bytes
|
||||||
ErrorReturnOrigin bool
|
ErrorReturnOrigin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +99,12 @@ func ToUTF8DropErrors(content []byte) []byte {
|
|||||||
func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
||||||
charsetLabel, _ := DetectEncoding(content)
|
charsetLabel, _ := DetectEncoding(content)
|
||||||
if charsetLabel == "UTF-8" {
|
if charsetLabel == "UTF-8" {
|
||||||
return maybeRemoveBOM(content, opts)
|
// even if the detected charset is UTF-8, the content may contain invalid UTF-8 sequences, so need to convert it to valid UTF-8
|
||||||
|
content = maybeRemoveBOM(content, opts)
|
||||||
|
if opts.ErrorReturnOrigin {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
return bytes.ToValidUTF8(content, opts.ErrorReplacement)
|
||||||
}
|
}
|
||||||
|
|
||||||
encoding, _ := Lookup(charsetLabel)
|
encoding, _ := Lookup(charsetLabel)
|
||||||
@@ -123,11 +128,7 @@ func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
|||||||
if opts.ErrorReturnOrigin {
|
if opts.ErrorReturnOrigin {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
if opts.ErrorReplacement == nil {
|
decoded = append(decoded, opts.ErrorReplacement...)
|
||||||
decoded = append(decoded, content[idx+n])
|
|
||||||
} else {
|
|
||||||
decoded = append(decoded, opts.ErrorReplacement...)
|
|
||||||
}
|
|
||||||
idx += n + 1
|
idx += n + 1
|
||||||
}
|
}
|
||||||
return maybeRemoveBOM(decoded, opts)
|
return maybeRemoveBOM(decoded, opts)
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ func TestToUTF8DropErrors(t *testing.T) {
|
|||||||
|
|
||||||
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00})
|
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00})
|
||||||
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
|
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
|
||||||
|
|
||||||
|
assert.Equal(t, "caf ", string(ToUTF8DropErrors([]byte("caf\xe9"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectEncoding(t *testing.T) {
|
func TestDetectEncoding(t *testing.T) {
|
||||||
|
|||||||
+11
-15
@@ -125,6 +125,11 @@ func syncGitConfig(ctx context.Context) (err error) {
|
|||||||
return err
|
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
|
// Apply user's git config options last so they take precedence over builtin defaults
|
||||||
for k, v := range setting.GitConfig.Options {
|
for k, v := range setting.GitConfig.Options {
|
||||||
if err = configSet(ctx, strings.ToLower(k), v); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, _, err = gitcmd.NewCommand("config", "--global").
|
if _, _, err = gitcmd.NewCommand("config", "--global", "--replace-all").
|
||||||
AddDynamicArguments(key, value).
|
AddDynamicArguments(key, value).
|
||||||
RunStdString(ctx); err != nil {
|
RunStdString(ctx); err != nil {
|
||||||
return fmt.Errorf("failed to set git global config %s, err: %w", key, err)
|
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 {
|
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 {
|
if err == nil {
|
||||||
// already exist
|
// already exist
|
||||||
return nil
|
return nil
|
||||||
@@ -207,18 +212,9 @@ func configAddNonExist(ctx context.Context, key, value string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configUnsetAll(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)
|
_, _, err := gitcmd.NewCommand("config", "--global", "--unset-all").AddDynamicArguments(key, "^"+regexp.QuoteMeta(value)+"$").RunStdString(ctx)
|
||||||
if err == nil {
|
if err != nil && !gitcmd.IsErrorExitCode(err, 5) { // 5: no such key or no matching value
|
||||||
// exist, need to remove
|
return fmt.Errorf("failed to unset git global config %s, err: %w", key, err)
|
||||||
_, _, 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
|
|
||||||
}
|
}
|
||||||
if gitcmd.IsErrorExitCode(err, 1) {
|
return nil
|
||||||
// not exist
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to get git config %s, err: %w", key, err)
|
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,9 @@ func TestGitConfig(t *testing.T) {
|
|||||||
assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed"))
|
assert.NoError(t, configSetNonExist(ctx, "test.key-a", "val-a-changed"))
|
||||||
assert.False(t, gitConfigContains("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, 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.True(t, gitConfigContains("key-a = val-a-changed"))
|
||||||
|
|
||||||
assert.NoError(t, configAddNonExist(ctx, "test.key-b", "val-b"))
|
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.NoError(t, configAddNonExist(ctx, "test.key-b", "val-2b"))
|
||||||
assert.True(t, gitConfigContains("key-b = val-b"))
|
assert.True(t, gitConfigContains("key-b = val-b"))
|
||||||
assert.True(t, gitConfigContains("key-b = val-2b"))
|
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.NoError(t, configUnsetAll(ctx, "test.key-b", "val-b"))
|
||||||
assert.False(t, gitConfigContains("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)()
|
defer test.MockVariableValue(&setting.GitConfig)()
|
||||||
|
|
||||||
assert.Empty(t, setting.GitConfig.Options)
|
assert.Empty(t, setting.GitConfig.Options)
|
||||||
|
assert.NoError(t, configSet(t.Context(), "gc.reflogExpire", "90"))
|
||||||
assert.NoError(t, syncGitConfig(t.Context()))
|
assert.NoError(t, syncGitConfig(t.Context()))
|
||||||
assert.True(t, gitConfigContains("commitGraph = true")) // builtin default config
|
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["sync-test.cfg-key-a"] = "CfgValA"
|
||||||
setting.GitConfig.Options["core.commitgraph"] = "false"
|
setting.GitConfig.Options["core.commitgraph"] = "false"
|
||||||
|
|||||||
+13
-17
@@ -5,6 +5,7 @@ package git
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -200,22 +201,18 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
|
|||||||
}
|
}
|
||||||
if old {
|
if old {
|
||||||
begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
begin, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
||||||
end, _ = strconv.ParseInt(groups["endOld"], 10, 64)
|
end, _ = strconv.ParseInt(cmp.Or(groups["endOld"], "1"), 10, 64)
|
||||||
// init otherLine with begin of opposite side
|
// init otherLine with begin of opposite side
|
||||||
otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
otherLine, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
||||||
} else {
|
} else {
|
||||||
begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
begin, _ = strconv.ParseInt(groups["beginNew"], 10, 64)
|
||||||
if groups["endNew"] != "" {
|
end, _ = strconv.ParseInt(cmp.Or(groups["endNew"], "1"), 10, 64)
|
||||||
end, _ = strconv.ParseInt(groups["endNew"], 10, 64)
|
|
||||||
} else {
|
|
||||||
end = 0
|
|
||||||
}
|
|
||||||
// init otherLine with begin of opposite side
|
// init otherLine with begin of opposite side
|
||||||
otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
otherLine, _ = strconv.ParseInt(groups["beginOld"], 10, 64)
|
||||||
}
|
}
|
||||||
end += begin // end is for real only the number of lines in hunk
|
end += begin // end is for real only the number of lines in hunk
|
||||||
// lof is between begin and end
|
// lof is between begin and end
|
||||||
if begin <= line && end >= line {
|
if begin <= line && line < end {
|
||||||
hunk = append(hunk, lof)
|
hunk = append(hunk, lof)
|
||||||
currentLine = begin
|
currentLine = begin
|
||||||
continue
|
continue
|
||||||
@@ -265,27 +262,26 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
|
|||||||
oldBegin = otherLine
|
oldBegin = otherLine
|
||||||
newBegin = currentLine
|
newBegin = currentLine
|
||||||
}
|
}
|
||||||
// headers + hunk header
|
|
||||||
newHunk := make([]string, headerLines)
|
|
||||||
// transfer existing headers
|
|
||||||
copy(newHunk, hunk[:headerLines])
|
|
||||||
// transfer last n lines
|
|
||||||
newHunk = append(newHunk, hunk[len(hunk)-numbersOfLine-1:]...)
|
|
||||||
// calculate newBegin, ... by counting lines
|
// calculate newBegin, ... by counting lines
|
||||||
for i := len(hunk) - 1; i >= len(hunk)-numbersOfLine; i-- {
|
start := len(hunk)
|
||||||
switch hunk[i][0] {
|
for count := 0; count < numbersOfLine && start > headerLines+1; {
|
||||||
|
start--
|
||||||
|
switch hunk[start][0] {
|
||||||
case '+':
|
case '+':
|
||||||
newBegin--
|
newBegin--
|
||||||
newNumOfLines++
|
newNumOfLines++
|
||||||
case '-':
|
case '-':
|
||||||
oldBegin--
|
oldBegin--
|
||||||
oldNumOfLines++
|
oldNumOfLines++
|
||||||
|
case '\\':
|
||||||
|
continue
|
||||||
default:
|
default:
|
||||||
oldBegin--
|
oldBegin--
|
||||||
newBegin--
|
newBegin--
|
||||||
newNumOfLines++
|
newNumOfLines++
|
||||||
oldNumOfLines++
|
oldNumOfLines++
|
||||||
}
|
}
|
||||||
|
count++
|
||||||
}
|
}
|
||||||
|
|
||||||
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
|
// "git diff" outputs "@@ -1 +1,3 @@" for "OLD" => "A\nB\nC"
|
||||||
@@ -294,9 +290,9 @@ func CutDiffAroundLine(originalDiff io.Reader, line int64, old bool, numbersOfLi
|
|||||||
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
|
// For example: "L1\nL2" => "A\nB", then the patch shows "L2" as line 1 on the left (deleted part)
|
||||||
|
|
||||||
// construct the new hunk header
|
// construct the new hunk header
|
||||||
newHunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
|
hunk[headerLines] = fmt.Sprintf("@@ -%d,%d +%d,%d @@",
|
||||||
oldBegin, oldNumOfLines, newBegin, newNumOfLines)
|
oldBegin, oldNumOfLines, newBegin, newNumOfLines)
|
||||||
return strings.Join(newHunk, "\n"), nil
|
return strings.Join(append(hunk[:headerLines+1], hunk[start:]...), "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAffectedFiles returns the affected files between two commits
|
// GetAffectedFiles returns the affected files between two commits
|
||||||
|
|||||||
@@ -120,10 +120,18 @@ func TestCutDiffAroundLine(t *testing.T) {
|
|||||||
assert.Empty(t, emptyResult)
|
assert.Empty(t, emptyResult)
|
||||||
|
|
||||||
// Line is out of scope
|
// Line is out of scope
|
||||||
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 434, false, 0)
|
emptyResult, err = CutDiffAroundLine(strings.NewReader(exampleDiff), 7, false, 3)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Empty(t, emptyResult)
|
assert.Empty(t, emptyResult)
|
||||||
|
|
||||||
|
noNewlineDiff := "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+a"
|
||||||
|
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, false, 2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,1 +1,1 @@\n-a\n\\ No newline at end of file\n+a", result)
|
||||||
|
result, err = CutDiffAroundLine(strings.NewReader(noNewlineDiff), 1, true, 2)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-a", result)
|
||||||
|
|
||||||
// Handle minus diffs properly
|
// Handle minus diffs properly
|
||||||
minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
|
minusDiff, err := CutDiffAroundLine(strings.NewReader(breakingDiff), 2, false, 4)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type FastImportFile struct {
|
|||||||
|
|
||||||
type FastImportCommit struct {
|
type FastImportCommit struct {
|
||||||
Ref string
|
Ref string
|
||||||
|
Parent string
|
||||||
Message string
|
Message string
|
||||||
Files []FastImportFile
|
Files []FastImportFile
|
||||||
|
|
||||||
@@ -72,6 +73,9 @@ func ForceFastImport(ctx context.Context, repo RepositoryFacade, commits []FastI
|
|||||||
|
|
||||||
msg := util.IfZero(c.Message, fmt.Sprintf("test commit %d", i+1))
|
msg := util.IfZero(c.Message, fmt.Sprintf("test commit %d", i+1))
|
||||||
_, _ = fmt.Fprintf(buf, "data %d\n%s\n", len(msg), msg)
|
_, _ = fmt.Fprintf(buf, "data %d\n%s\n", len(msg), msg)
|
||||||
|
if c.Parent != "" {
|
||||||
|
_, _ = fmt.Fprintf(buf, "from %s\n", c.Parent)
|
||||||
|
}
|
||||||
|
|
||||||
for _, f := range c.Files {
|
for _, f := range c.Files {
|
||||||
mode := util.IfZero(f.Mode, EntryModeBlob)
|
mode := util.IfZero(f.Mode, EntryModeBlob)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
// merge base between commits.
|
// merge base between commits.
|
||||||
func FetchRemoteCommit(ctx context.Context, repo, remoteRepo RepositoryFacade, commitID string) error {
|
func FetchRemoteCommit(ctx context.Context, repo, remoteRepo RepositoryFacade, commitID string) error {
|
||||||
return LockWriteAndDo(ctx, repo, func(ctx context.Context) error {
|
return LockWriteAndDo(ctx, repo, func(ctx context.Context) error {
|
||||||
return gitcmd.NewCommand("fetch", "--no-tags").
|
return gitcmd.NewCommand("fetch", "--no-tags", "--no-write-commit-graph").
|
||||||
AddDynamicArguments(gitrepo.RepoLocalPath(remoteRepo)).
|
AddDynamicArguments(gitrepo.RepoLocalPath(remoteRepo)).
|
||||||
AddDynamicArguments(commitID).
|
AddDynamicArguments(commitID).
|
||||||
WithRepo(repo).Run(ctx)
|
WithRepo(repo).Run(ctx)
|
||||||
|
|||||||
+7
-3
@@ -71,12 +71,12 @@ func DefaultFeatures() *Features {
|
|||||||
func loadGitVersionFeatures() (*Features, error) {
|
func loadGitVersionFeatures() (*Features, error) {
|
||||||
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
|
stdout, _, runErr := gitcmd.NewCommand("version").RunStdString(context.Background())
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
return nil, runErr
|
return nil, fmt.Errorf(`failed to run command "git version": %w`, runErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
|
ver, err := parseGitVersionLine(strings.TrimSpace(stdout))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf(`failed to parse output of command "git version": %w`, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
features := &Features{gitVersion: ver, UsingGogit: isGogit}
|
features := &Features{gitVersion: ver, UsingGogit: isGogit}
|
||||||
@@ -108,7 +108,11 @@ func parseGitVersionLine(s string) (*version.Version, error) {
|
|||||||
if len(versionFields) > 3 {
|
if len(versionFields) > 3 {
|
||||||
versionFields = versionFields[:3]
|
versionFields = versionFields[:3]
|
||||||
}
|
}
|
||||||
return version.NewVersion(strings.Join(versionFields, "."))
|
ver, err := version.NewVersion(strings.Join(versionFields, "."))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid git version: %q: %w", s, err)
|
||||||
|
}
|
||||||
|
return ver, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkGitVersionCompatibility(gitVer *version.Version) error {
|
func checkGitVersionCompatibility(gitVer *version.Version) error {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const (
|
|||||||
StderrPathSpec StderrPrefix = "fatal: pathspec"
|
StderrPathSpec StderrPrefix = "fatal: pathspec"
|
||||||
StderrBadRevision StderrPrefix = "fatal: bad revision"
|
StderrBadRevision StderrPrefix = "fatal: bad revision"
|
||||||
StderrNoSuchPath StderrPrefix = "fatal: no such path"
|
StderrNoSuchPath StderrPrefix = "fatal: no such path"
|
||||||
|
StderrBadObject StderrPrefix = "fatal: bad object "
|
||||||
|
|
||||||
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
|
StderrNoSuchRemote1 StderrPrefix = "fatal: no such remote" // git < 2.30, exit status 128
|
||||||
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2
|
StderrNoSuchRemote2 StderrPrefix = "error: no such remote" // git >= 2.30. exit status 2
|
||||||
|
|||||||
+11
-7
@@ -56,13 +56,17 @@ func parseLsTreeLine(line []byte) (*LsTreeEntry, error) {
|
|||||||
return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err)
|
return nil, fmt.Errorf("invalid ls-tree output (invalid object id): %q, err: %w", line, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(entryName) > 0 && entryName[0] == '"' {
|
entry.Name, err = UnquoteDiffFilePath(string(entryName))
|
||||||
entry.Name, err = strconv.Unquote(string(entryName))
|
if err != nil {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err)
|
||||||
return nil, fmt.Errorf("invalid ls-tree output (invalid name): %q, err: %w", line, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
entry.Name = string(entryName)
|
|
||||||
}
|
}
|
||||||
return entry, nil
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UnquoteDiffFilePath(path string) (string, error) {
|
||||||
|
// ref: see the document of git config's "core.quotepath"
|
||||||
|
if !strings.HasPrefix(path, `"`) {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
return strconv.Unquote(path)
|
||||||
|
}
|
||||||
@@ -415,8 +415,26 @@ func (repo *Repository) GetCommitsFromIDs(ctx context.Context, commitIDs []strin
|
|||||||
return commits
|
return commits
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsCommitInBranch check if the commit is on the branch
|
// IsCommitInRepo checks if the commit exists in the repository.
|
||||||
|
func IsCommitInRepo(ctx context.Context, repo gitrepo.RepositoryFacade, commitID string) (bool, error) {
|
||||||
|
// "--quiet" makes it exit with 1 and no output if the commit doesn't exist
|
||||||
|
_, _, err := gitcmd.NewCommand("rev-parse", "--verify", "--quiet").
|
||||||
|
AddDynamicArguments(commitID + "^{commit}").
|
||||||
|
WithRepo(repo).
|
||||||
|
RunStdString(ctx)
|
||||||
|
if gitcmd.IsErrorExitCode(err, 1) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCommitInBranch checks if the commit is on the branch.
|
||||||
|
// It returns false (no error) if the commit doesn't exist or isn't on the branch.
|
||||||
func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commitID, branch string) (r bool, err error) {
|
func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commitID, branch string) (r bool, err error) {
|
||||||
|
if exist, err := IsCommitInRepo(ctx, repo, commitID); err != nil || !exist {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
stdout, _, err := gitcmd.NewCommand("branch", "--contains").
|
stdout, _, err := gitcmd.NewCommand("branch", "--contains").
|
||||||
AddDynamicArguments(commitID, branch).
|
AddDynamicArguments(commitID, branch).
|
||||||
WithRepo(repo).
|
WithRepo(repo).
|
||||||
@@ -424,7 +442,7 @@ func IsCommitInBranch(ctx context.Context, repo gitrepo.RepositoryFacade, commit
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return len(stdout) > 0, err
|
return len(stdout) > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCommitBranchStart returns the commit where the branch diverged
|
// GetCommitBranchStart returns the commit where the branch diverged
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ func TestGetCommitWithBadCommitID(t *testing.T) {
|
|||||||
assert.True(t, IsErrNotExist(err))
|
assert.True(t, IsErrNotExist(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsCommitInRepo(t *testing.T) {
|
||||||
|
bareRepo1, err := OpenRepositoryLocal(t.Context(), filepath.Join(testReposDir, "repo1_bare"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer bareRepo1.Close()
|
||||||
|
|
||||||
|
exist, err := IsCommitInRepo(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, exist)
|
||||||
|
|
||||||
|
exist, err = IsCommitInRepo(t.Context(), bareRepo1, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, exist)
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsCommitInBranch(t *testing.T) {
|
func TestIsCommitInBranch(t *testing.T) {
|
||||||
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
bareRepo1Path := filepath.Join(testReposDir, "repo1_bare")
|
||||||
bareRepo1, err := OpenRepositoryLocal(t.Context(), bareRepo1Path)
|
bareRepo1, err := OpenRepositoryLocal(t.Context(), bareRepo1Path)
|
||||||
@@ -83,6 +97,10 @@ func TestIsCommitInBranch(t *testing.T) {
|
|||||||
result, err = IsCommitInBranch(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2")
|
result, err = IsCommitInBranch(t.Context(), bareRepo1, "2839944139e0de9737a044f78b0e4b40d989a9e3", "branch2")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.False(t, result)
|
assert.False(t, result)
|
||||||
|
|
||||||
|
result, err = IsCommitInBranch(t.Context(), bareRepo1, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "branch1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRepository_CommitsBetween(t *testing.T) {
|
func TestRepository_CommitsBetween(t *testing.T) {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ func initGlobCompiler(g *globCompiler, pattern string, separators []rune) (Glob,
|
|||||||
|
|
||||||
regex, err := regexp.Compile(g.regexpPattern)
|
regex, err := regexp.Compile(g.regexpPattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to compile regexp: %w", err)
|
return nil, fmt.Errorf("failed to compile glob regexp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
g.regexp = regex
|
g.regexp = regex
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package htmlutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SrcSetCandidate struct {
|
||||||
|
SrcLink string
|
||||||
|
SrcDesc []string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
asciiSpaces = " \t\n\r\f"
|
||||||
|
asciiSpacesAndComma = " \t\n\r\f,"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseSrcset(srcset string) (candidates []SrcSetCandidate) {
|
||||||
|
// ref: https://html.spec.whatwg.org/multipage/images.html#parse-a-srcset-attribute
|
||||||
|
// It can't simply use "split" because comma can appear in URLs and descriptors can be parenthesized and contain commas.
|
||||||
|
extractLink := func() (string, bool) {
|
||||||
|
srcset = strings.TrimLeft(srcset, asciiSpacesAndComma)
|
||||||
|
if srcset == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
end := strings.IndexAny(srcset, asciiSpaces)
|
||||||
|
if end == -1 {
|
||||||
|
end = len(srcset)
|
||||||
|
}
|
||||||
|
link := srcset[:end]
|
||||||
|
srcset = srcset[end:]
|
||||||
|
if strings.HasSuffix(link, ",") {
|
||||||
|
return strings.TrimRight(link, ","), false
|
||||||
|
}
|
||||||
|
return link, true
|
||||||
|
}
|
||||||
|
extractDesc := func() (string, bool) {
|
||||||
|
srcset = strings.TrimLeft(srcset, asciiSpaces)
|
||||||
|
if srcset == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
inParentheses := false
|
||||||
|
for i := range len(srcset) {
|
||||||
|
c := srcset[i]
|
||||||
|
if inParentheses {
|
||||||
|
inParentheses = c != ')'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '(' {
|
||||||
|
inParentheses = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.IndexByte(asciiSpacesAndComma, c) != -1 {
|
||||||
|
desc := srcset[:i]
|
||||||
|
srcset = srcset[i+1:]
|
||||||
|
return desc, c != ','
|
||||||
|
}
|
||||||
|
}
|
||||||
|
desc := srcset
|
||||||
|
srcset = ""
|
||||||
|
return desc, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
link, hasDesc := extractLink()
|
||||||
|
if link == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var descriptors []string
|
||||||
|
if hasDesc {
|
||||||
|
for {
|
||||||
|
desc, hasMoreDesc := extractDesc()
|
||||||
|
if desc != "" {
|
||||||
|
descriptors = append(descriptors, desc)
|
||||||
|
}
|
||||||
|
if !hasMoreDesc {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates = append(candidates, SrcSetCandidate{SrcLink: link, SrcDesc: descriptors})
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeSrcset(candidates []SrcSetCandidate) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, c := range candidates {
|
||||||
|
if i != 0 {
|
||||||
|
sb.WriteString(", ")
|
||||||
|
}
|
||||||
|
link := c.SrcLink
|
||||||
|
if prefix, ok := strings.CutSuffix(link, ","); ok {
|
||||||
|
link = prefix + "%2C"
|
||||||
|
}
|
||||||
|
sb.WriteString(link)
|
||||||
|
for _, d := range c.SrcDesc {
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
sb.WriteString(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package htmlutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modules/util"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSrcset(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
candidates []SrcSetCandidate
|
||||||
|
encoded string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bare URL",
|
||||||
|
input: "image.png",
|
||||||
|
candidates: []SrcSetCandidate{{SrcLink: "image.png"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare URLs",
|
||||||
|
input: "a.png, b.png , c.png,",
|
||||||
|
candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png"}, {SrcLink: "c.png"}},
|
||||||
|
encoded: "a.png, b.png, c.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty candidates",
|
||||||
|
input: ", ,\ta.png 1x,, ,b.png 2x, ,",
|
||||||
|
candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
|
||||||
|
encoded: "a.png 1x, b.png 2x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing commas in URL",
|
||||||
|
input: "a.png,, b.png 2x",
|
||||||
|
candidates: []SrcSetCandidate{{SrcLink: "a.png"}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
|
||||||
|
encoded: "a.png, b.png 2x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comma in URL",
|
||||||
|
input: "comma,in,url.png",
|
||||||
|
candidates: []SrcSetCandidate{{SrcLink: "comma,in,url.png"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "embedded data URL",
|
||||||
|
input: "data:image/png;base64,abcd 1x, image.png 2x",
|
||||||
|
candidates: []SrcSetCandidate{{SrcLink: "data:image/png;base64,abcd", SrcDesc: []string{"1x"}}, {SrcLink: "image.png", SrcDesc: []string{"2x"}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parenthesized descriptor",
|
||||||
|
input: "a.png (x,y) 1x, b.png 2x",
|
||||||
|
candidates: []SrcSetCandidate{{SrcLink: "a.png", SrcDesc: []string{"(x,y)", "1x"}}, {SrcLink: "b.png", SrcDesc: []string{"2x"}}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
candidates := ParseSrcset(test.input)
|
||||||
|
assert.Equal(t, test.candidates, candidates)
|
||||||
|
encoded := EncodeSrcset(candidates)
|
||||||
|
assert.Equal(t, util.IfZero(test.encoded, test.input), encoded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("encode trailing commas", func(t *testing.T) {
|
||||||
|
encoded := EncodeSrcset([]SrcSetCandidate{
|
||||||
|
{SrcLink: "image,"},
|
||||||
|
{SrcLink: "image,,", SrcDesc: []string{"2x"}},
|
||||||
|
{SrcLink: "image.svg?variant=light,", SrcDesc: []string{"3x"}},
|
||||||
|
})
|
||||||
|
assert.Equal(t, "image%2C, image,%2C 2x, image.svg?variant=light%2C 3x", encoded)
|
||||||
|
assert.Equal(t, []SrcSetCandidate{
|
||||||
|
{SrcLink: "image%2C"},
|
||||||
|
{SrcLink: "image,%2C", SrcDesc: []string{"2x"}},
|
||||||
|
{SrcLink: "image.svg?variant=light%2C", SrcDesc: []string{"3x"}},
|
||||||
|
}, ParseSrcset(encoded))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -60,11 +61,13 @@ func serveSetHeaderContentRelated(w http.ResponseWriter, contentType string) {
|
|||||||
header.Set("X-Content-Type-Options", "nosniff")
|
header.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
csp := serveHeaderCspDefault
|
csp := serveHeaderCspDefault
|
||||||
if strings.HasPrefix(contentType, "application/pdf") {
|
if mediaType, _, err := mime.ParseMediaType(contentType); err == nil { // browsers may parse malformed types differently
|
||||||
csp = serveHeaderCspPdf
|
if mediaType == "application/pdf" {
|
||||||
}
|
csp = serveHeaderCspPdf
|
||||||
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
|
}
|
||||||
csp = serveHeaderCspAudioVideo
|
if strings.HasPrefix(mediaType, "video/") || strings.HasPrefix(mediaType, "audio/") {
|
||||||
|
csp = serveHeaderCspAudioVideo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if csp != "" {
|
if csp != "" {
|
||||||
header.Set("Content-Security-Policy", csp)
|
header.Set("Content-Security-Policy", csp)
|
||||||
|
|||||||
@@ -117,9 +117,10 @@ func TestServeSetHeaderContentRelated(t *testing.T) {
|
|||||||
{"", serveHeaderCspDefault},
|
{"", serveHeaderCspDefault},
|
||||||
{"any", serveHeaderCspDefault},
|
{"any", serveHeaderCspDefault},
|
||||||
{"application/pdf", serveHeaderCspPdf},
|
{"application/pdf", serveHeaderCspPdf},
|
||||||
{"application/pdf; other", serveHeaderCspPdf},
|
{"application/pdf; charset=binary", serveHeaderCspPdf},
|
||||||
{"audio/mp4", serveHeaderCspAudioVideo},
|
{"audio/mp4", serveHeaderCspAudioVideo},
|
||||||
{"video/ogg; other", serveHeaderCspAudioVideo},
|
{"video/ogg; codecs=theora", serveHeaderCspAudioVideo},
|
||||||
|
{"video/mp4;,text/html", serveHeaderCspDefault},
|
||||||
{typesniffer.MimeTypeImageSvg, serveHeaderCspDefault},
|
{typesniffer.MimeTypeImageSvg, serveHeaderCspDefault},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type Decoder interface {
|
|||||||
// Interface represents an interface to handle json data
|
// Interface represents an interface to handle json data
|
||||||
type Interface interface {
|
type Interface interface {
|
||||||
Marshal(v any) ([]byte, error)
|
Marshal(v any) ([]byte, error)
|
||||||
MarshalWrite(w io.Writer, v any) error
|
MarshalWriteRelaxed(w io.Writer, v any) error
|
||||||
Unmarshal(data []byte, v any) error
|
Unmarshal(data []byte, v any) error
|
||||||
NewEncoder(writer io.Writer) Encoder
|
NewEncoder(writer io.Writer) Encoder
|
||||||
NewDecoder(reader io.Reader) Decoder
|
NewDecoder(reader io.Reader) Decoder
|
||||||
@@ -37,9 +37,10 @@ func Marshal(v any) ([]byte, error) {
|
|||||||
return DefaultJSONHandler.Marshal(v)
|
return DefaultJSONHandler.Marshal(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalWrite writes the JSON encoding of v to the given writer
|
// MarshalWriteRelaxed writes the JSON encoding of v to the given writer
|
||||||
func MarshalWrite(w io.Writer, v any) error {
|
// It tolerates invalid UTF-8 in strings and replaces them with the Unicode replacement character U+FFFD.
|
||||||
return DefaultJSONHandler.MarshalWrite(w, v)
|
func MarshalWriteRelaxed(w io.Writer, v any) error {
|
||||||
|
return DefaultJSONHandler.MarshalWriteRelaxed(w, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unmarshal decodes object from bytes
|
// Unmarshal decodes object from bytes
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
type JSONv2 struct {
|
type JSONv2 struct {
|
||||||
marshalOptions jsonv2.Options
|
marshalOptions jsonv2.Options
|
||||||
|
marshalRelaxedOptions jsonv2.Options
|
||||||
marshalKeepOptionalEmptyOptions jsonv2.Options
|
marshalKeepOptionalEmptyOptions jsonv2.Options
|
||||||
unmarshalOptions jsonv2.Options
|
unmarshalOptions jsonv2.Options
|
||||||
unmarshalCaseInsensitiveOptions jsonv2.Options
|
unmarshalCaseInsensitiveOptions jsonv2.Options
|
||||||
@@ -26,6 +27,8 @@ func init() {
|
|||||||
jsonv2.FormatNilMapAsNull(true),
|
jsonv2.FormatNilMapAsNull(true),
|
||||||
}
|
}
|
||||||
jsonV2.marshalOptions = jsonv2.JoinOptions(commonMarshalOptions...)
|
jsonV2.marshalOptions = jsonv2.JoinOptions(commonMarshalOptions...)
|
||||||
|
// when handling user's data, we want to allow invalid UTF-8 to be replaced with the replacement character, rather than returning an error.
|
||||||
|
jsonV2.marshalRelaxedOptions = jsonv2.JoinOptions(append(commonMarshalOptions, jsontext.AllowInvalidUTF8(true))...)
|
||||||
jsonV2.unmarshalOptions = jsonv2.DefaultOptionsV2()
|
jsonV2.unmarshalOptions = jsonv2.DefaultOptionsV2()
|
||||||
|
|
||||||
// By default, "json/v2" omitempty removes all `""` empty strings, no matter where it comes from.
|
// By default, "json/v2" omitempty removes all `""` empty strings, no matter where it comes from.
|
||||||
@@ -53,8 +56,8 @@ func (j *JSONv2) Marshal(v any) ([]byte, error) {
|
|||||||
return jsonv2.Marshal(v, j.marshalOptions)
|
return jsonv2.Marshal(v, j.marshalOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *JSONv2) MarshalWrite(w io.Writer, v any) error {
|
func (j *JSONv2) MarshalWriteRelaxed(w io.Writer, v any) error {
|
||||||
return jsonv2.MarshalWrite(w, v, j.marshalOptions)
|
return jsonv2.MarshalWrite(w, v, j.marshalRelaxedOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *JSONv2) Unmarshal(data []byte, v any) error {
|
func (j *JSONv2) Unmarshal(data []byte, v any) error {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
|
|||||||
// Client is used to communicate with a LFS source
|
// Client is used to communicate with a LFS source
|
||||||
type Client interface {
|
type Client interface {
|
||||||
BatchSize() int
|
BatchSize() int
|
||||||
Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error
|
Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error // ref is the full git ref name the objects belong to, may be empty
|
||||||
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
|
Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewClient(t *testing.T) {
|
func TestNewClient(t *testing.T) {
|
||||||
u, _ := url.Parse("file:///test")
|
u, _ := url.Parse("file:///test")
|
||||||
c := newClient(u, nil)
|
c := newClient(u, nil)
|
||||||
assert.IsType(t, &FilesystemClient{}, c)
|
require.IsType(t, &FilesystemClient{}, c)
|
||||||
|
localPath, err := c.(*FilesystemClient).objectPath("invalid") //nolint:forcetypeassert // type is expected
|
||||||
|
assert.Empty(t, localPath)
|
||||||
|
assert.ErrorIs(t, err, ErrInvalidOIDFormat)
|
||||||
|
|
||||||
u, _ = url.Parse("https://test.com/lfs")
|
u, _ = url.Parse("https://test.com/lfs")
|
||||||
c = newClient(u, nil)
|
c = newClient(u, nil)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type FilesystemClient struct {
|
|||||||
lfsDir string
|
lfsDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchSize returns the preferred size of batchs to process
|
// BatchSize returns the preferred size of batches to process
|
||||||
func (c *FilesystemClient) BatchSize() int {
|
func (c *FilesystemClient) BatchSize() int {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -29,17 +29,23 @@ func newFilesystemClient(endpoint *url.URL) *FilesystemClient {
|
|||||||
return &FilesystemClient{lfsDir}
|
return &FilesystemClient{lfsDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FilesystemClient) objectPath(oid string) string {
|
func (c *FilesystemClient) objectPath(oid string) (string, error) {
|
||||||
return filepath.Join(c.lfsDir, oid[0:2], oid[2:4], oid)
|
if !isValidOid(oid) {
|
||||||
|
return "", ErrInvalidOIDFormat
|
||||||
|
}
|
||||||
|
// HINT: LFS-INTERNAL-PATH-PATTERN
|
||||||
|
return filepath.Join(c.lfsDir, oid[0:2], oid[2:4], oid), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download reads the specific LFS object from the target path
|
// Download reads the specific LFS object from the target path
|
||||||
func (c *FilesystemClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
|
func (c *FilesystemClient) Download(ctx context.Context, _ string, objects []Pointer, callback DownloadCallback) error {
|
||||||
for _, object := range objects {
|
for _, object := range objects {
|
||||||
p := Pointer{object.Oid, object.Size}
|
p := Pointer{object.Oid, object.Size}
|
||||||
|
|
||||||
objectPath := c.objectPath(p.Oid)
|
objectPath, err := c.objectPath(p.Oid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
f, err := os.Open(objectPath)
|
f, err := os.Open(objectPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -57,8 +63,10 @@ func (c *FilesystemClient) Upload(ctx context.Context, objects []Pointer, callba
|
|||||||
for _, object := range objects {
|
for _, object := range objects {
|
||||||
p := Pointer{object.Oid, object.Size}
|
p := Pointer{object.Oid, object.Size}
|
||||||
|
|
||||||
objectPath := c.objectPath(p.Oid)
|
objectPath, err := c.objectPath(p.Oid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(objectPath), os.ModePerm); err != nil {
|
if err := os.MkdirAll(filepath.Dir(objectPath), os.ModePerm); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -67,18 +67,18 @@ func (c *HTTPClient) transferNames() []string {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {
|
func (c *HTTPClient) batch(ctx context.Context, operation, ref string, objects []Pointer) (*BatchResponse, error) {
|
||||||
log.Trace("BATCH operation with objects: %v", objects)
|
log.Trace("BATCH operation with objects: %v", objects)
|
||||||
|
|
||||||
url := c.endpoint + "/objects/batch"
|
url := c.endpoint + "/objects/batch"
|
||||||
|
|
||||||
// Original: In some lfs server implementations, they require the ref attribute. #32838
|
// `ref` is optional, but some servers like aliyun require it, while others like Hugging Face reject an empty or null one
|
||||||
// `ref` is an "optional object describing the server ref that the objects belong to"
|
|
||||||
// but some (incorrect) lfs servers like aliyun require it, so maybe adding an empty ref here doesn't break the correct ones.
|
|
||||||
// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
|
// https://github.com/git-lfs/git-lfs/blob/a32a02b44bf8a511aa14f047627c49e1a7fd5021/docs/api/batch.md?plain=1#L37
|
||||||
//
|
// Related issues: #32838, #33453, #39891
|
||||||
// UPDATE: it can't use "empty ref" here because it breaks others like https://github.com/go-gitea/gitea/issues/33453
|
request := &BatchRequest{Operation: operation, Transfers: c.transferNames(), Objects: objects}
|
||||||
request := &BatchRequest{operation, c.transferNames(), nil, objects}
|
if ref != "" {
|
||||||
|
request.Ref = &Reference{Name: ref}
|
||||||
|
}
|
||||||
|
|
||||||
payload := new(bytes.Buffer)
|
payload := new(bytes.Buffer)
|
||||||
err := json.NewEncoder(payload).Encode(request)
|
err := json.NewEncoder(payload).Encode(request)
|
||||||
@@ -113,17 +113,17 @@ func (c *HTTPClient) batch(ctx context.Context, operation string, objects []Poin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download reads the specific LFS object from the LFS server
|
// Download reads the specific LFS object from the LFS server
|
||||||
func (c *HTTPClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
|
func (c *HTTPClient) Download(ctx context.Context, ref string, objects []Pointer, callback DownloadCallback) error {
|
||||||
return c.performOperation(ctx, objects, callback, nil)
|
return c.performOperation(ctx, ref, objects, callback, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload sends the specific LFS object to the LFS server
|
// Upload sends the specific LFS object to the LFS server
|
||||||
func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
|
func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
|
||||||
return c.performOperation(ctx, objects, nil, callback)
|
return c.performOperation(ctx, "", objects, nil, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
|
// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch
|
||||||
func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
|
func (c *HTTPClient) performOperation(ctx context.Context, ref string, objects []Pointer, dc DownloadCallback, uc UploadCallback) error {
|
||||||
if len(objects) == 0 {
|
if len(objects) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc
|
|||||||
operation = "upload"
|
operation = "upload"
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := c.batch(ctx, operation, objects)
|
result, err := c.batch(ctx, operation, ref, objects)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ func TestHTTPClientDownload(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "download", batchRequest.Operation)
|
assert.Equal(t, "download", batchRequest.Operation)
|
||||||
|
assert.Equal(t, &Reference{Name: "refs/heads/main"}, batchRequest.Ref)
|
||||||
assert.Len(t, batchRequest.Objects, 1)
|
assert.Len(t, batchRequest.Objects, 1)
|
||||||
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
|
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
|
||||||
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
|
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
|
||||||
@@ -248,7 +249,7 @@ func TestHTTPClientDownload(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := client.Download(t.Context(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
|
err := client.Download(t.Context(), "refs/heads/main", []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error {
|
||||||
if objectError != nil {
|
if objectError != nil {
|
||||||
return objectError
|
return objectError
|
||||||
}
|
}
|
||||||
@@ -283,6 +284,7 @@ func TestHTTPClientUpload(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "upload", batchRequest.Operation)
|
assert.Equal(t, "upload", batchRequest.Operation)
|
||||||
|
assert.Nil(t, batchRequest.Ref)
|
||||||
assert.Len(t, batchRequest.Objects, 1)
|
assert.Len(t, batchRequest.Objects, 1)
|
||||||
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
|
assert.Equal(t, p.Oid, batchRequest.Objects[0].Oid)
|
||||||
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
|
assert.Equal(t, p.Size, batchRequest.Objects[0].Size)
|
||||||
|
|||||||
+25
-11
@@ -10,9 +10,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// spec: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
|
// spec: https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
|
||||||
@@ -47,8 +49,6 @@ func ReadPointer(reader io.Reader) (Pointer, error) {
|
|||||||
return ReadPointerFromBuffer(buf)
|
return ReadPointerFromBuffer(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`)
|
|
||||||
|
|
||||||
// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.
|
// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.
|
||||||
func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
|
func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
|
||||||
var p Pointer
|
var p Pointer
|
||||||
@@ -65,7 +65,7 @@ func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
|
|||||||
|
|
||||||
// spec says "key/value pairs MUST be sorted alphabetically in ascending order (version is exception and must be the first)"
|
// spec says "key/value pairs MUST be sorted alphabetically in ascending order (version is exception and must be the first)"
|
||||||
oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
|
oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
|
||||||
if len(oid) != 64 || !oidPattern.MatchString(oid) {
|
if !isValidOid(oid) {
|
||||||
return p, ErrInvalidOIDFormat
|
return p, ErrInvalidOIDFormat
|
||||||
}
|
}
|
||||||
size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
|
size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
|
||||||
@@ -79,13 +79,24 @@ func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isValidOid(oid string) bool {
|
||||||
|
if len(oid) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range len(oid) {
|
||||||
|
c := oid[i]
|
||||||
|
valid := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
|
||||||
|
if !valid {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// IsValid checks if the pointer has a valid structure.
|
// IsValid checks if the pointer has a valid structure.
|
||||||
// It doesn't check if the pointed-to-content exists.
|
// It doesn't check if the pointed-to-content exists.
|
||||||
func (p Pointer) IsValid() bool {
|
func (p Pointer) IsValid() bool {
|
||||||
if len(p.Oid) != 64 {
|
if !isValidOid(p.Oid) {
|
||||||
return false
|
|
||||||
}
|
|
||||||
if !oidPattern.MatchString(p.Oid) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if p.Size < 0 {
|
if p.Size < 0 {
|
||||||
@@ -102,10 +113,13 @@ func (p Pointer) StringContent() string {
|
|||||||
|
|
||||||
// RelativePath returns the relative storage path of the pointer
|
// RelativePath returns the relative storage path of the pointer
|
||||||
func (p Pointer) RelativePath() string {
|
func (p Pointer) RelativePath() string {
|
||||||
if len(p.Oid) < 5 {
|
if !isValidOid(p.Oid) {
|
||||||
return p.Oid
|
setting.PanicInDevOrTesting("invalid LFS OID: %s", p.Oid)
|
||||||
|
return "invalid-lfs-oid-" + util.PathJoinRelX(p.Oid)
|
||||||
}
|
}
|
||||||
|
// HINT: LFS-INTERNAL-PATH-PATTERN: this path pattern is only used by Gitea internally,
|
||||||
|
// It isn't the same as the path pattern used by Git LFS, which is: <oid[0:2]>/<oid[2:4]>/<oid>
|
||||||
|
// However, there are other parts of Gitea that rely on such path pattern.
|
||||||
return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:])
|
return path.Join(p.Oid[0:2], p.Oid[2:4], p.Oid[4:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/modules/setting"
|
||||||
|
"gitea.dev/modules/test"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,8 +25,10 @@ func TestRelativePath(t *testing.T) {
|
|||||||
expected := path.Join("4d", "7a", "214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
|
expected := path.Join("4d", "7a", "214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393")
|
||||||
assert.Equal(t, expected, p.RelativePath())
|
assert.Equal(t, expected, p.RelativePath())
|
||||||
|
|
||||||
|
defer test.MockVariableValue(&setting.IsProd, true)()
|
||||||
|
defer test.MockVariableValue(&setting.IsInTesting, false)()
|
||||||
p2 := Pointer{Oid: "4d7a"}
|
p2 := Pointer{Oid: "4d7a"}
|
||||||
assert.Equal(t, "4d7a", p2.RelativePath())
|
assert.Equal(t, "invalid-lfs-oid-4d7a", p2.RelativePath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsValid(t *testing.T) {
|
func TestIsValid(t *testing.T) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func b64encode(data []byte) string {
|
|||||||
func camoHandleLink(link string) string {
|
func camoHandleLink(link string) string {
|
||||||
if setting.Camo.Enabled {
|
if setting.Camo.Enabled {
|
||||||
lnkURL, err := url.Parse(link)
|
lnkURL, err := url.Parse(link)
|
||||||
if err == nil && lnkURL.IsAbs() && !strings.HasPrefix(link, setting.AppURL) &&
|
if err == nil && (lnkURL.Scheme == "http" || lnkURL.Scheme == "https") && !strings.HasPrefix(link, setting.AppURL) &&
|
||||||
(setting.Camo.Always || lnkURL.Scheme != "https") {
|
(setting.Camo.Always || lnkURL.Scheme != "https") {
|
||||||
return CamoEncode(link)
|
return CamoEncode(link)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ func TestCamoHandleLink(t *testing.T) {
|
|||||||
setting.AppURL = "https://gitea.com"
|
setting.AppURL = "https://gitea.com"
|
||||||
// Test media proxy
|
// Test media proxy
|
||||||
setting.Camo.Enabled = true
|
setting.Camo.Enabled = true
|
||||||
|
setting.Camo.Always = false
|
||||||
setting.Camo.ServerURL = "https://image.proxy"
|
setting.Camo.ServerURL = "https://image.proxy"
|
||||||
setting.Camo.HMACKey = "geheim"
|
setting.Camo.HMACKey = "geheim"
|
||||||
|
dataURL := "data:image/png;base64,i//V"
|
||||||
|
assert.Equal(t, dataURL, camoHandleLink(dataURL))
|
||||||
|
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
"https://gitea.com/img.jpg",
|
"https://gitea.com/img.jpg",
|
||||||
@@ -29,6 +32,7 @@ func TestCamoHandleLink(t *testing.T) {
|
|||||||
camoHandleLink("http://testimages.org/img.jpg"))
|
camoHandleLink("http://testimages.org/img.jpg"))
|
||||||
|
|
||||||
setting.Camo.Always = true
|
setting.Camo.Always = true
|
||||||
|
assert.Equal(t, dataURL, camoHandleLink(dataURL))
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
"https://gitea.com/img.jpg",
|
"https://gitea.com/img.jpg",
|
||||||
camoHandleLink("https://gitea.com/img.jpg"))
|
camoHandleLink("https://gitea.com/img.jpg"))
|
||||||
|
|||||||
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user