mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-26 13:29:52 +09:00
Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068355cabd | ||
|
|
3f93080f48 | ||
|
|
d2572c7342 | ||
|
|
b70f7aeda1 | ||
|
|
3af1ee88a5 | ||
|
|
6d348e57bc | ||
|
|
565e12d47b | ||
|
|
25fff4c043 | ||
|
|
6e2cf10687 | ||
|
|
1e86e1e45a | ||
|
|
0d946be8be | ||
|
|
3f01f06d3b | ||
|
|
9fe8653e45 | ||
|
|
1350cf8a38 | ||
|
|
f64ee9d1ed | ||
|
|
b5c6b80790 | ||
|
|
c9da6553a2 | ||
|
|
dbc17db0c2 | ||
|
|
bf7b6f8bd4 | ||
|
|
76c768edca | ||
|
|
320b44df58 | ||
|
|
078bb4fa07 | ||
|
|
883ed3b0bd | ||
|
|
eee37e0560 | ||
|
|
4e515cce99 | ||
|
|
d8e179f28f | ||
|
|
616dbddda4 | ||
|
|
6024280a23 | ||
|
|
96c651382f | ||
|
|
50be834d24 | ||
|
|
f4bff43d46 | ||
|
|
11de54dd41 | ||
|
|
1c3ae57f35 | ||
|
|
cbcf36cded | ||
|
|
5a665c0b0e | ||
|
|
0aa0ea17bc | ||
|
|
11cd8ff69a | ||
|
|
33498f828d | ||
|
|
1dac1bb2f8 | ||
|
|
38cf2a2cfb | ||
|
|
3604189b08 | ||
|
|
ba4db8a2d9 | ||
|
|
0acbcc58a7 | ||
|
|
88b56d408d | ||
|
|
1c92062c69 | ||
|
|
51938de973 | ||
|
|
21fda8f5be | ||
|
|
9ab9c18919 | ||
|
|
e2a0a87ae0 | ||
|
|
92044649a0 | ||
|
|
94011d2850 | ||
|
|
fe252be0ae | ||
|
|
cca0c65a6c | ||
|
|
6387c8ba6e | ||
|
|
6eab271921 | ||
|
|
eab225f095 | ||
|
|
00a637295e | ||
|
|
4e64b3a65d | ||
|
|
b71adfe1ad | ||
|
|
e5c6669751 | ||
|
|
e0e10052e0 | ||
|
|
c461575af3 | ||
|
|
7fb9602961 | ||
|
|
a8e80ebc23 | ||
|
|
8ab5d31cf3 | ||
|
|
b2af380d66 | ||
|
|
d2603a8b4a | ||
|
|
9eac9bd032 | ||
|
|
784d88814f | ||
|
|
a62dfffbe7 | ||
|
|
035dd58664 | ||
|
|
0d9ce64f76 | ||
|
|
4a77fbce28 | ||
|
|
2b37732d5f | ||
|
|
2695b47887 | ||
|
|
9c685dedbb | ||
|
|
e9b3917042 | ||
|
|
d7bc52beea | ||
|
|
ccd38f9a70 | ||
|
|
f47e3d930d | ||
|
|
9972bee41f | ||
|
|
375e6ea038 | ||
|
|
31c435454b | ||
|
|
a54324e2b7 | ||
|
|
d141dc729c |
@@ -51,7 +51,7 @@ jobs:
|
||||
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
|
||||
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
|
||||
done
|
||||
# clean branch name to get the folder name in S3
|
||||
# clean branch name to get the folder name in the object storage
|
||||
- name: Get cleaned branch name
|
||||
id: clean_name
|
||||
env:
|
||||
@@ -60,18 +60,16 @@ jobs:
|
||||
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//')
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
- name: upload binaries to s3
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
|
||||
nightly-container:
|
||||
runs-on: namespace-profile-gitea-release-docker
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
|
||||
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
|
||||
done
|
||||
# clean branch name to get the folder name in S3
|
||||
# clean branch name to get the folder name in the object storage
|
||||
- name: Get cleaned branch name
|
||||
id: clean_name
|
||||
env:
|
||||
@@ -61,18 +61,16 @@ jobs:
|
||||
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
- name: upload binaries to s3
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
- name: Install GH CLI
|
||||
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
|
||||
with:
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
cosign sign-blob "$f" --bundle "$f.sigstore.json" --yes
|
||||
echo "$GPG_PASSPHRASE" | gpg --pinentry-mode loopback --passphrase-fd 0 --batch --yes --detach-sign -u "$GPG_FINGERPRINT" --output "$f.asc" "$f"
|
||||
done
|
||||
# clean branch name to get the folder name in S3
|
||||
# clean branch name to get the folder name in the object storage
|
||||
- name: Get cleaned branch name
|
||||
id: clean_name
|
||||
env:
|
||||
@@ -64,18 +64,16 @@ jobs:
|
||||
REF_NAME=$(echo "$REF" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\/v//' -e 's/release\/v//')
|
||||
echo "Cleaned name is ${REF_NAME}"
|
||||
echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
- name: configure aws
|
||||
uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0
|
||||
with:
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
- name: upload binaries to s3
|
||||
- name: upload binaries to cloudflare r2
|
||||
env:
|
||||
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
CLOUDFLARE_R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
|
||||
CLOUDFLARE_R2_BUCKET: ${{ secrets.CLOUDFLARE_R2_BUCKET }}
|
||||
BRANCH: ${{ steps.clean_name.outputs.branch }}
|
||||
run: |
|
||||
aws s3 sync dist/release "s3://$AWS_S3_BUCKET/gitea/$BRANCH" --no-progress
|
||||
aws s3 sync dist/release "s3://$CLOUDFLARE_R2_BUCKET/gitea/$BRANCH" --endpoint-url "https://$CLOUDFLARE_R2_ACCOUNT_ID.r2.cloudflarestorage.com" --no-progress
|
||||
- name: Install GH CLI
|
||||
uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1
|
||||
with:
|
||||
|
||||
@@ -4,6 +4,102 @@ This changelog goes through the changes that have been made in each release
|
||||
without substantial changes to our git log; to see the highlights of what has
|
||||
been added to each release, please refer to the [blog](https://blog.gitea.com).
|
||||
|
||||
## [1.27.2](https://github.com/go-gitea/gitea/releases/tag/v1.27.2) - 2026-08-14
|
||||
|
||||
* SECURITY
|
||||
* Fix: update collaborator access mode and httpsign (#38894, #38862) (#38895)
|
||||
* Refactor: external render (#38885) (#38898)
|
||||
* Fix(actions): resolve pull_request_target reusable workflows at the base commit (#38886) (#38897)
|
||||
* Refactor: markup render (#38864) (#38869)
|
||||
* Fix(deps): update dependency mermaid to v11.16.1 (#38816)
|
||||
* Fix(auth): set WebAuthn user verification per request (#38805) (#38810)
|
||||
* Fix: render highlight language (#38793) (#38795)
|
||||
|
||||
* ENHANCEMENTS
|
||||
* enhance: add missing npm package metadata properties (#38826) (#38831)
|
||||
|
||||
* BUGFIXES
|
||||
* fix(actions): keep github.event.inputs as strings for workflow_dispatch (#38899) (#38908)
|
||||
* fix(actions): let a rerun of selected jobs read the previous attempt's artifacts (#38857) (#38901)
|
||||
* fix(lfs): accept successful transfer responses (#38866) (#38875)
|
||||
* fix(packages): ignore nested Package.swift (#38788) (#38836)
|
||||
* fix: drop newline-bearing member names in arch ParsePackage (#38102) (#38830)
|
||||
* fix(storage): fix Azure Blob dump failing with file does not exist (#38814) (#38828)
|
||||
* fix(migration): migration deletion returned json redirection (#38796) (#38825)
|
||||
* fix(ui): change underlines to default browser style (#38819) (#38823)
|
||||
* fix(actions): allow cancelling runs without running jobs (#35842) (#38812)
|
||||
* fix(actions): evaluate each `${{ }}` part on its own (#38754) (#38797)
|
||||
* fix(actions): write an action task report in one transaction (#38792) (#38794)
|
||||
* fix: markup link (#38764) (#38765)
|
||||
* fix: set a minio part size when the content size is unknown (#38753) (#38755)
|
||||
* fix: bad path escape in subpath archive download (#38749) (#38750)
|
||||
* fix: remove the pull merge box from UI when the refreshed page doesn't contain it (#38742) (#38744)
|
||||
* fix(markdown): fix double strikethough on code (#38707) (#38729)
|
||||
* fix(lfs): failed upload deletes a concurrent upload's meta object (#38693) (#38722)
|
||||
* fix: correct full url when using sub-path (#38712) (#38716)
|
||||
* fix: avoid markup render panic (#38698) (#38703)
|
||||
* fix(ui): too many participants shown in commit avatar stacks (#38689) (#38700)
|
||||
* fix: support HEAD requests on Alpine registry APKINDEX.tar.gz (#38686) (#38688)
|
||||
* fix(migrations): use all configured GitHub tokens (#38841) (#38846)
|
||||
|
||||
## [1.27.1](https://github.com/go-gitea/gitea/releases/tag/v1.27.1) - 2026-07-27
|
||||
|
||||
* SECURITY
|
||||
* fix(oauth2): enforce mandatory 2FA policy on OAuth2 authorize/grant endpoints (#38591) (#38606)
|
||||
|
||||
* API
|
||||
* fix(api): align Swagger schemas for UserSettings and TopicListResponse (#38590) (#38592)
|
||||
|
||||
* ENHANCEMENTS
|
||||
* enhance: improve diff contrast in light and dark themes (#37477) (#38574)
|
||||
|
||||
* BUGFIXES
|
||||
* fix: skip OIDC end-session after password login for OAuth2 users (#38439) (#38666)
|
||||
* fix: make Actions log parser support multiple line message encoding (#38659) (#38664)
|
||||
* fix(actions): use base branch ref for pull_request_target context (#38636) (#38657)
|
||||
* fix(actions): skip already-approved runs in `ApproveRuns` (#38653) (#38654)
|
||||
* fix: orgmode render include path (#38642) (#38645)
|
||||
* fix(actions): cancel tasks immediately when the runner stopped reporting (#38616) (#38644)
|
||||
* fix(issues): fix label bulk-load key and reduce log noise in LoadLabel (#38632) (#38643)
|
||||
* fix(actions): improve runner list status sorting, labels and task job links (#38586) (#38633)
|
||||
* fix(actions): correctness and hardening fixes (#38518) (#38631)
|
||||
* fix(repo): prevent double-write redirect collisions on dependency errors, fix ui (#38627) (#38628)
|
||||
* fix: delete repo-scoped rows of seven more tables when deleting a repository (#38534) (#38618)
|
||||
* fix(webhook): remove slack channel name check (#38608) (#38612)
|
||||
* fix: download dropdown menu clipped on the branches page (#38604) (#38609)
|
||||
* fix(project): prevent database mutations on invalid MoveIssues payload (#38600) (#38602)
|
||||
* fix(actions): make SingleWorkflow.Marshal round-trip multi-line run blocks (stop silent job stranding) (#38520) (#38599)
|
||||
* fix(file-tree): handle submodule links and missing view container (#38033) (#38589)
|
||||
* fix(actions): fail unexpandable reusable workflow callers and decouple the job emitter's cross-run processing (#38565) (#38587)
|
||||
* fix: keep serving valid ACME cert when renewal fails at startup (#38554) (#38583)
|
||||
* fix: branch protection user list (#38570) (#38584)
|
||||
* fix(pulls): respect diff.orderFile in diff file tree (#38566) (#38578)
|
||||
* fix(issue): make issue action (issue list batch operation) elements have correct attributes (#38575) (#38580)
|
||||
* fix(actions): support `matrix` when evaluating workflow `if` expression (#38474) (#38557)
|
||||
* fix(actions): align status icon span for Safari rendering (#38558) (#38562)
|
||||
* fix: revert git clone http redirection forbidden (#38530) (#38545)
|
||||
* fix: clean up orphaned user-keyed tables in deleteUser (#38511) (#38514)
|
||||
* fix(actions): coerce workflow_dispatch boolean inputs to native types (#38472) (#38521)
|
||||
* fix: make the merge box button red if some checks fail (#38508) (#38516)
|
||||
* fix(pull): sign the commit when updating a branch by merge (#38441) (#38499)
|
||||
* fix: make commit message merge correctly (#38490) (#38502)
|
||||
* fix(actions): explain why a blocked or waiting job has not started (#38476) (#38498)
|
||||
* fix(actions): make `cancelled()` work in job `if` evaluation (#38495) (#38497)
|
||||
* fix(actions): show retention info on hover for expired artifacts (#38477) (#38493)
|
||||
* fix(actions): group reusable-workflow matrix legs in the workflow graph (#38475) (#38492)
|
||||
* fix: full file highlighting for git diff with CR char (#38484) (#38491)
|
||||
* fix(packages): serve noarch Alpine index for any requested architecture (#38479) (#38486)
|
||||
* fix: 500 error when updating user visibility (#38480) (#38483)
|
||||
* fix(actions): make job list item fully clickable (#38462) (#38471)
|
||||
* fix: mail template for push event (#38467) (#38468)
|
||||
* fix: make "test push webhook" always work (#38425) (#38455)
|
||||
* fix(actions): prevent bulk actions from affecting all runners (#38453) (#38457)
|
||||
* fix(org): align follow button and wrap description (#38448) (#38454)
|
||||
* fix(actions): populate `github.event` for scheduled runs (#38446) (#38452)
|
||||
|
||||
* MISC
|
||||
* refactor: git patch apply (#38637) (#38638)
|
||||
|
||||
## [1.27.0](https://github.com/go-gitea/gitea/releases/tag/v1.27.0) - 2026-07-13
|
||||
|
||||
* BREAKING
|
||||
|
||||
@@ -5,60 +5,14 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/private"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func defaultLoggingFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "logger",
|
||||
Usage: `Logger name - will default to "default"`,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "writer",
|
||||
Usage: "Name of the log writer - will default to mode",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "level",
|
||||
Usage: "Logging level for the new logger",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "stacktrace-level",
|
||||
Aliases: []string{"L"},
|
||||
Usage: "Stacktrace logging level",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "flags",
|
||||
Aliases: []string{"F"},
|
||||
Usage: "Flags for the logger",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "expression",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Matching expression for the logger",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "prefix",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Prefix for the logger",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "color",
|
||||
Usage: "Use color in the logs",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLoggingCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "logging",
|
||||
@@ -91,92 +45,6 @@ func newLoggingCommand() *cli.Command {
|
||||
},
|
||||
},
|
||||
Action: runReleaseReopenLogging,
|
||||
}, {
|
||||
Name: "remove",
|
||||
Usage: "Remove a logger",
|
||||
ArgsUsage: "[name] Name of logger to remove",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
}, &cli.StringFlag{
|
||||
Name: "logger",
|
||||
Usage: `Logger name - will default to "default"`,
|
||||
},
|
||||
},
|
||||
Action: runRemoveLogger,
|
||||
}, {
|
||||
Name: "add",
|
||||
Usage: "Add a logger",
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "file",
|
||||
Usage: "Add a file logger",
|
||||
Flags: append(defaultLoggingFlags(), []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "filename",
|
||||
Aliases: []string{"f"},
|
||||
Usage: "Filename for the logger - this must be set.",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "rotate",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "Rotate logs",
|
||||
},
|
||||
&cli.Int64Flag{
|
||||
Name: "max-size",
|
||||
Aliases: []string{"s"},
|
||||
Usage: "Maximum size in bytes before rotation",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "daily",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Rotate logs daily",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "max-days",
|
||||
Aliases: []string{"D"},
|
||||
Usage: "Maximum number of daily logs to keep",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "compress",
|
||||
Aliases: []string{"z"},
|
||||
Usage: "Compress rotated logs",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "compression-level",
|
||||
Aliases: []string{"Z"},
|
||||
Usage: "Compression level to use",
|
||||
},
|
||||
}...),
|
||||
Action: runAddFileLogger,
|
||||
}, {
|
||||
Name: "conn",
|
||||
Usage: "Add a net conn logger",
|
||||
Flags: append(defaultLoggingFlags(), []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "reconnect-on-message",
|
||||
Aliases: []string{"R"},
|
||||
Usage: "Reconnect to host for every message",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "reconnect",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "Reconnect to host when connection is dropped",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "protocol",
|
||||
Aliases: []string{"P"},
|
||||
Usage: "Set protocol to use: tcp, unix, or udp (defaults to tcp)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "address",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Host address and port to connect to (defaults to :7020)",
|
||||
},
|
||||
}...),
|
||||
Action: runAddConnLogger,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
Name: "log-sql",
|
||||
Usage: "Set LogSQL",
|
||||
@@ -195,107 +63,6 @@ func newLoggingCommand() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func runRemoveLogger(ctx context.Context, c *cli.Command) error {
|
||||
setup(ctx, c.Bool("debug"))
|
||||
logger := c.String("logger")
|
||||
if len(logger) == 0 {
|
||||
logger = log.DEFAULT
|
||||
}
|
||||
writer := c.Args().First()
|
||||
|
||||
extra := private.RemoveLogger(ctx, logger, writer)
|
||||
return handleCliResponseExtra(extra)
|
||||
}
|
||||
|
||||
func runAddConnLogger(ctx context.Context, c *cli.Command) error {
|
||||
setup(ctx, c.Bool("debug"))
|
||||
vals := map[string]any{}
|
||||
mode := "conn"
|
||||
vals["net"] = "tcp"
|
||||
if c.IsSet("protocol") {
|
||||
switch c.String("protocol") {
|
||||
case "udp":
|
||||
vals["net"] = "udp"
|
||||
case "unix":
|
||||
vals["net"] = "unix"
|
||||
}
|
||||
}
|
||||
if c.IsSet("address") {
|
||||
vals["address"] = c.String("address")
|
||||
} else {
|
||||
vals["address"] = ":7020"
|
||||
}
|
||||
if c.IsSet("reconnect") {
|
||||
vals["reconnect"] = c.Bool("reconnect")
|
||||
}
|
||||
if c.IsSet("reconnect-on-message") {
|
||||
vals["reconnectOnMsg"] = c.Bool("reconnect-on-message")
|
||||
}
|
||||
return commonAddLogger(ctx, c, mode, vals)
|
||||
}
|
||||
|
||||
func runAddFileLogger(ctx context.Context, c *cli.Command) error {
|
||||
setup(ctx, c.Bool("debug"))
|
||||
vals := map[string]any{}
|
||||
mode := "file"
|
||||
if c.IsSet("filename") {
|
||||
vals["filename"] = c.String("filename")
|
||||
} else {
|
||||
return errors.New("filename must be set when creating a file logger")
|
||||
}
|
||||
if c.IsSet("rotate") {
|
||||
vals["rotate"] = c.Bool("rotate")
|
||||
}
|
||||
if c.IsSet("max-size") {
|
||||
vals["maxsize"] = c.Int64("max-size")
|
||||
}
|
||||
if c.IsSet("daily") {
|
||||
vals["daily"] = c.Bool("daily")
|
||||
}
|
||||
if c.IsSet("max-days") {
|
||||
vals["maxdays"] = c.Int("max-days")
|
||||
}
|
||||
if c.IsSet("compress") {
|
||||
vals["compress"] = c.Bool("compress")
|
||||
}
|
||||
if c.IsSet("compression-level") {
|
||||
vals["compressionLevel"] = c.Int("compression-level")
|
||||
}
|
||||
return commonAddLogger(ctx, c, mode, vals)
|
||||
}
|
||||
|
||||
func commonAddLogger(ctx context.Context, c *cli.Command, mode string, vals map[string]any) error {
|
||||
if len(c.String("level")) > 0 {
|
||||
vals["level"] = log.LevelFromString(c.String("level")).String()
|
||||
}
|
||||
if len(c.String("stacktrace-level")) > 0 {
|
||||
vals["stacktraceLevel"] = log.LevelFromString(c.String("stacktrace-level")).String()
|
||||
}
|
||||
if len(c.String("expression")) > 0 {
|
||||
vals["expression"] = c.String("expression")
|
||||
}
|
||||
if len(c.String("prefix")) > 0 {
|
||||
vals["prefix"] = c.String("prefix")
|
||||
}
|
||||
if len(c.String("flags")) > 0 {
|
||||
vals["flags"] = log.FlagsFromString(c.String("flags"))
|
||||
}
|
||||
if c.IsSet("color") {
|
||||
vals["colorize"] = c.Bool("color")
|
||||
}
|
||||
logger := log.DEFAULT
|
||||
if c.IsSet("logger") {
|
||||
logger = c.String("logger")
|
||||
}
|
||||
writer := mode
|
||||
if c.IsSet("writer") {
|
||||
writer = c.String("writer")
|
||||
}
|
||||
|
||||
extra := private.AddLogger(ctx, logger, writer, mode, vals)
|
||||
return handleCliResponseExtra(extra)
|
||||
}
|
||||
|
||||
func runPauseLogging(ctx context.Context, c *cli.Command) error {
|
||||
setup(ctx, c.Bool("debug"))
|
||||
userMsg := private.PauseLogging(ctx)
|
||||
|
||||
@@ -606,7 +606,8 @@ ENABLED = true
|
||||
;; * https://github.com/hickford/git-credential-oauth
|
||||
;; * https://github.com/git-ecosystem/git-credential-manager
|
||||
;; * https://gitea.com/gitea/tea
|
||||
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea
|
||||
;; * Gitea App (the official Gitea mobile app)
|
||||
;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea, gitea-app
|
||||
;;
|
||||
;; 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.
|
||||
@@ -1122,7 +1123,6 @@ LEVEL = Info
|
||||
;ALLOW_FORK_WITHOUT_MAXIMUM_LIMIT = true
|
||||
|
||||
;; Allow to fork repositories into the same owner (user or organization)
|
||||
;; This feature is experimental, not fully tested, and may be changed in the future
|
||||
;ALLOW_FORK_INTO_SAME_OWNER = false
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -87,6 +87,7 @@ require (
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/quasoft/websspi v1.1.2
|
||||
github.com/redis/go-redis/v9 v9.21.0
|
||||
github.com/rhysd/actionlint v1.7.12
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
github.com/sassoftware/go-rpmutils v0.4.0
|
||||
@@ -244,7 +245,6 @@ require (
|
||||
github.com/prometheus/common v0.68.1 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rhysd/actionlint v1.7.12 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
|
||||
@@ -9,10 +9,10 @@ package actions
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
@@ -147,7 +147,7 @@ type FindArtifactsOptions struct {
|
||||
db.ListOptions
|
||||
RepoID int64
|
||||
RunID int64
|
||||
RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy artifacts have run_attempt_id=0)
|
||||
RunAttemptIDs []int64 // empty means every attempt; pass 0 to target legacy artifacts, which have run_attempt_id=0
|
||||
ArtifactName string
|
||||
Status int
|
||||
FinalizedArtifactsV4 bool
|
||||
@@ -167,8 +167,8 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
if opts.RunID > 0 {
|
||||
cond = cond.And(builder.Eq{"run_id": opts.RunID})
|
||||
}
|
||||
if opts.RunAttemptID.Has() {
|
||||
cond = cond.And(builder.Eq{"run_attempt_id": opts.RunAttemptID.Value()})
|
||||
if len(opts.RunAttemptIDs) > 0 {
|
||||
cond = cond.And(builder.In("run_attempt_id", opts.RunAttemptIDs))
|
||||
}
|
||||
if opts.ArtifactName != "" {
|
||||
cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName})
|
||||
@@ -185,6 +185,27 @@ func (opts FindArtifactsOptions) ToConds() builder.Cond {
|
||||
return cond
|
||||
}
|
||||
|
||||
// FindReadableArtifacts returns the artifacts of opts.RunAttemptIDs, only keeps the ones from a newer attempt.
|
||||
func FindReadableArtifacts(ctx context.Context, opts FindArtifactsOptions) ([]*ActionArtifact, error) {
|
||||
arts, err := db.Find[ActionArtifact](ctx, opts)
|
||||
if err != nil || len(opts.RunAttemptIDs) <= 1 {
|
||||
return arts, err
|
||||
}
|
||||
return keepLatestAttemptArtifacts(arts), nil
|
||||
}
|
||||
|
||||
// keepLatestAttemptArtifacts keeps, per name, only the artifacts of the newest attempt that has it.
|
||||
// A v3 artifact is one row per uploaded file, so the whole group of the winning attempt is kept.
|
||||
func keepLatestAttemptArtifacts(arts []*ActionArtifact) []*ActionArtifact {
|
||||
latest := make(map[string]int64)
|
||||
for _, art := range arts {
|
||||
latest[art.ArtifactName] = max(latest[art.ArtifactName], art.RunAttemptID)
|
||||
}
|
||||
return slices.DeleteFunc(arts, func(art *ActionArtifact) bool {
|
||||
return art.RunAttemptID != latest[art.ArtifactName]
|
||||
})
|
||||
}
|
||||
|
||||
// ActionArtifactMeta is the meta-data of an artifact
|
||||
type ActionArtifactMeta struct {
|
||||
ArtifactName string
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package actions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestKeepLatestAttemptArtifacts(t *testing.T) {
|
||||
arts := []*ActionArtifact{
|
||||
{ID: 1, RunAttemptID: 1, ArtifactName: "inherited"},
|
||||
{ID: 2, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "a.txt"},
|
||||
{ID: 3, RunAttemptID: 1, ArtifactName: "shadowed", ArtifactPath: "b.txt"},
|
||||
{ID: 4, RunAttemptID: 2, ArtifactName: "shadowed", ArtifactPath: "c.txt"},
|
||||
{ID: 5, RunAttemptID: 2, ArtifactName: "own"},
|
||||
}
|
||||
|
||||
// the whole "shadowed" group of attempt 1 is dropped, its multi-file rows must not mix with attempt 2
|
||||
var ids []int64
|
||||
for _, art := range keepLatestAttemptArtifacts(arts) {
|
||||
ids = append(ids, art.ID)
|
||||
}
|
||||
assert.Equal(t, []int64{1, 4, 5}, ids)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
@@ -62,14 +63,16 @@ func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error)
|
||||
attempt.Run = run
|
||||
}
|
||||
|
||||
if attempt.TriggerUser == nil {
|
||||
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return attempt.LoadTriggerUser(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
// LoadTriggerUser loads the attempt's trigger user if not already loaded.
|
||||
func (attempt *ActionRunAttempt) LoadTriggerUser(ctx context.Context) (err error) {
|
||||
if attempt.TriggerUser != nil {
|
||||
return nil
|
||||
}
|
||||
attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID)
|
||||
return err
|
||||
}
|
||||
|
||||
func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) {
|
||||
@@ -94,6 +97,56 @@ func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum in
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// GetArtifactAttemptIDs returns the IDs of the attempts whose artifacts the job may read, newest first,
|
||||
// always including the job's own attempt.
|
||||
// An attempt that re-ran only some of the run's jobs keeps the artifacts of the attempt it re-ran from,
|
||||
// because the jobs it passed through never upload them again; a rerun of the whole run starts over.
|
||||
func GetArtifactAttemptIDs(ctx context.Context, job *ActionRunJob) ([]int64, error) {
|
||||
if job.Attempt <= 1 || job.RunAttemptID == 0 {
|
||||
return []int64{job.RunAttemptID}, nil
|
||||
}
|
||||
|
||||
attempts, err := ListRunAttemptsByRunID(ctx, job.RunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a newer attempt is never readable, and attempt 1 has nothing older to continue into
|
||||
candidateIDs := container.FilterSlice(attempts, func(a *ActionRunAttempt) (int64, bool) {
|
||||
return a.ID, a.Attempt > 1 && a.Attempt <= job.Attempt
|
||||
})
|
||||
passThroughAttemptIDs, err := findPassThroughAttemptIDs(ctx, candidateIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(attempts))
|
||||
for _, attempt := range attempts {
|
||||
if attempt.Attempt > job.Attempt {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, attempt.ID)
|
||||
if !slices.Contains(passThroughAttemptIDs, attempt.ID) {
|
||||
// stops at the first attempt that passed no job through
|
||||
break
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// findPassThroughAttemptIDs narrows the given attempts to those that were a rerun of selected jobs:
|
||||
// only such a rerun clones jobs carrying a source task.
|
||||
// TODO: best-effort. Needs a better way to distinguish between "partial re-run" and "full re-run".
|
||||
func findPassThroughAttemptIDs(ctx context.Context, attemptIDs []int64) ([]int64, error) {
|
||||
passThroughAttemptIDs := make([]int64, 0, len(attemptIDs))
|
||||
return passThroughAttemptIDs, db.GetEngine(ctx).
|
||||
Table("action_run_job").
|
||||
Cols("run_attempt_id").
|
||||
In("run_attempt_id", attemptIDs).
|
||||
Where("source_task_id <> 0").
|
||||
Distinct("run_attempt_id").
|
||||
Find(&passThroughAttemptIDs)
|
||||
}
|
||||
|
||||
// FindConcurrentRunAttempts returns attempts in the given concurrency group and status set.
|
||||
// Results are unordered; callers must not depend on any particular row order.
|
||||
func FindConcurrentRunAttempts(ctx context.Context, repoID int64, concurrencyGroup string, statuses []Status) ([]*ActionRunAttempt, error) {
|
||||
|
||||
+75
-47
@@ -448,58 +448,74 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col
|
||||
return affected, RefreshReusableCallerStatus(ctx, parent)
|
||||
}
|
||||
|
||||
{
|
||||
// Other goroutines may aggregate the status of the attempt/run and update it too.
|
||||
// So we need to load the current jobs before updating the aggregate state.
|
||||
if job.RunAttemptID > 0 {
|
||||
attempt, err := GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, job.RunID, job.RunAttemptID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
attempt.Status = AggregateJobStatus(jobs)
|
||||
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||
attempt.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
||||
return 0, fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
||||
}
|
||||
} else {
|
||||
// TODO: Remove this fallback in the future.
|
||||
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
|
||||
// This path keeps those runs' status consistent when their jobs finish, including:
|
||||
// - jobs created before migration v331 and complete on the new version starts
|
||||
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
|
||||
run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, job.RepoID, job.RunID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
run.Status = AggregateJobStatus(jobs)
|
||||
if run.Started.IsZero() && run.Status.IsRunning() {
|
||||
run.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if run.Stopped.IsZero() && run.Status.IsDone() {
|
||||
run.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
|
||||
return 0, fmt.Errorf("update run %d: %w", run.ID, err)
|
||||
}
|
||||
}
|
||||
if err := refreshRunStatus(ctx, job.RepoID, job.RunID, job.RunAttemptID, StatusUnknown); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// refreshRunStatus recomputes the status of an attempt from the jobs currently stored and persists it.
|
||||
// The latest attempt propagates its status to its run, an older one only updates itself.
|
||||
// noJobsStatus settles an attempt without any job, which AggregateJobStatus cannot conclude on its own.
|
||||
func refreshRunStatus(ctx context.Context, repoID, runID, runAttemptID int64, noJobsStatus Status) error {
|
||||
// Other goroutines may aggregate the status of the attempt/run and update it too.
|
||||
// So we need to load the current jobs before updating the aggregate state.
|
||||
if runAttemptID > 0 {
|
||||
attempt, err := GetRunAttemptByRepoAndID(ctx, repoID, runAttemptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := GetRunJobsByRunAndAttemptID(ctx, runID, runAttemptID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attempt.Status = AggregateJobStatus(jobs)
|
||||
if len(jobs) == 0 {
|
||||
attempt.Status = noJobsStatus
|
||||
}
|
||||
if attempt.Started.IsZero() && attempt.Status.IsRunning() {
|
||||
attempt.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if attempt.Stopped.IsZero() && attempt.Status.IsDone() {
|
||||
attempt.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRunAttempt(ctx, attempt, "status", "started", "stopped"); err != nil {
|
||||
return fmt.Errorf("update run attempt %d: %w", attempt.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Remove this fallback in the future.
|
||||
// Legacy fallback: jobs created before migration v331 have RunAttemptID=0 and are NOT backfilled.
|
||||
// This path keeps those runs' status consistent when their jobs finish, including:
|
||||
// - jobs created before migration v331 and complete on the new version starts
|
||||
// - zombie/abandoned cleanup cron tasks that call UpdateRunJob on legacy jobs
|
||||
// - cancelling a legacy run whose jobs are all already done
|
||||
run, err := GetRunByRepoAndID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jobs, err := GetLatestAttemptJobsByRepoAndRunID(ctx, repoID, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run.Status = AggregateJobStatus(jobs)
|
||||
if len(jobs) == 0 {
|
||||
run.Status = noJobsStatus
|
||||
}
|
||||
if run.Started.IsZero() && run.Status.IsRunning() {
|
||||
run.Started = timeutil.TimeStampNow()
|
||||
}
|
||||
if run.Stopped.IsZero() && run.Status.IsDone() {
|
||||
run.Stopped = timeutil.TimeStampNow()
|
||||
}
|
||||
if err := UpdateRun(ctx, run, "status", "started", "stopped"); err != nil {
|
||||
return fmt.Errorf("update run %d: %w", run.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshReusableCallerStatus recomputes a reusable workflow caller's Status, Started and Stopped from its current direct children and persists the change.
|
||||
// No-op if caller is not a reusable caller.
|
||||
//
|
||||
@@ -660,6 +676,8 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob)
|
||||
return CancelJobs(ctx, jobsToCancel)
|
||||
}
|
||||
|
||||
// CancelJobs cancels every cancellable job it is given. It leaves the status of a run it
|
||||
// cancelled nothing in untouched, SettleRunAfterCancel is what gives such a run a final one.
|
||||
func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, error) {
|
||||
cancelledJobs := make([]*ActionRunJob, 0, len(jobs))
|
||||
|
||||
@@ -684,6 +702,16 @@ func CancelJobs(ctx context.Context, jobs []*ActionRunJob) ([]*ActionRunJob, err
|
||||
return cancelledJobs, nil
|
||||
}
|
||||
|
||||
// SettleRunAfterCancel gives a run a final status when cancelling it updated no job at all.
|
||||
// A run's status is otherwise only ever written as a side effect of a job update, so a run whose
|
||||
// jobs are all done already, or that has no job at all, would stay unfinished forever.
|
||||
func SettleRunAfterCancel(ctx context.Context, run *ActionRun) error {
|
||||
if run.Status.IsDone() {
|
||||
return nil
|
||||
}
|
||||
return refreshRunStatus(ctx, run.RepoID, run.ID, run.LatestAttemptID, StatusCancelled)
|
||||
}
|
||||
|
||||
// cancelOneJob cancels a single job and returns the post-cancel row
|
||||
func cancelOneJob(ctx context.Context, job *ActionRunJob) (*ActionRunJob, error) {
|
||||
if job.Status.IsDone() {
|
||||
|
||||
@@ -159,3 +159,12 @@ func (opts FindRunJobOptions) ToOrders() string {
|
||||
}
|
||||
|
||||
var _ db.FindOptionsOrder = FindRunJobOptions{}
|
||||
|
||||
// CountRunJobsByRunAndAttemptID counts the jobs belonging to the given run attempt.
|
||||
// It is used to enforce MaxJobNumPerRun when reusable-workflow expansion inserts new jobs.
|
||||
func CountRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (int64, error) {
|
||||
return db.Count[ActionRunJob](ctx, FindRunJobOptions{
|
||||
RunID: runID,
|
||||
RunAttemptID: optional.Some(runAttemptID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -197,3 +198,91 @@ func TestCancelJobs_NestedBlockedReusableCaller(t *testing.T) {
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
assert.Equal(t, StatusCancelled, gotRun.Status, "run must aggregate to Cancelled, not stay Blocked")
|
||||
}
|
||||
|
||||
func TestSettleRunAfterCancel(t *testing.T) {
|
||||
// A run that cancelling updates no job in, because its jobs all reached a final status already
|
||||
// or because it has none at all. Its own row has to be settled explicitly, or the run can never
|
||||
// finish and can never be deleted either.
|
||||
|
||||
newStuckRun := func(t *testing.T, withAttempt, withJob bool) (*ActionRun, []*ActionRunJob) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "stuck-waiting",
|
||||
RepoID: 4,
|
||||
Index: 9801,
|
||||
OwnerID: 1,
|
||||
WorkflowID: "test.yaml",
|
||||
TriggerUserID: 1,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
EventPayload: "{}",
|
||||
Status: StatusWaiting,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, run))
|
||||
|
||||
var runAttemptID int64
|
||||
if withAttempt {
|
||||
attempt := &ActionRunAttempt{RepoID: run.RepoID, RunID: run.ID, Attempt: 1, TriggerUserID: 1, Status: StatusWaiting}
|
||||
require.NoError(t, db.Insert(ctx, attempt))
|
||||
run.LatestAttemptID = attempt.ID
|
||||
require.NoError(t, UpdateRun(ctx, run, "latest_attempt_id"))
|
||||
runAttemptID = attempt.ID
|
||||
}
|
||||
|
||||
if !withJob {
|
||||
return run, nil
|
||||
}
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RunAttemptID: runAttemptID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "job1",
|
||||
JobID: "job1",
|
||||
Attempt: 1,
|
||||
Status: StatusSuccess,
|
||||
Stopped: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, job))
|
||||
return run, []*ActionRunJob{job}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
withAttempt bool
|
||||
withJob bool
|
||||
want Status
|
||||
}{
|
||||
{"done job", true, true, StatusSuccess},
|
||||
// Runs created before migration v331 have no attempt, their status lives on the run row itself.
|
||||
{"done job on a legacy run without attempt", false, true, StatusSuccess},
|
||||
// Aggregation cannot reach a final status without any job, so cancelling has to end the run itself.
|
||||
{"no job at all", true, false, StatusCancelled},
|
||||
{"no job at all on a legacy run without attempt", false, false, StatusCancelled},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
run, jobs := newStuckRun(t, tc.withAttempt, tc.withJob)
|
||||
|
||||
// mirrors what the CancelRun service does
|
||||
cancelled, err := CancelJobs(t.Context(), jobs)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, cancelled, "nothing is cancellable, so the run row has to be settled explicitly")
|
||||
require.NoError(t, SettleRunAfterCancel(t.Context(), run))
|
||||
|
||||
if tc.withAttempt {
|
||||
gotAttempt := unittest.AssertExistsAndLoadBean(t, &ActionRunAttempt{ID: run.LatestAttemptID})
|
||||
assert.Equal(t, tc.want, gotAttempt.Status)
|
||||
}
|
||||
gotRun := unittest.AssertExistsAndLoadBean(t, &ActionRun{ID: run.ID})
|
||||
assert.Equal(t, tc.want, gotRun.Status)
|
||||
assert.NotZero(t, gotRun.Stopped)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,15 +270,31 @@ func (opts FindRunnerOptions) ToConds() builder.Cond {
|
||||
return cond
|
||||
}
|
||||
|
||||
// runnerStatusOrderExpr builds an ORDER BY fragment that ranks runners by their
|
||||
// computed status (see ActionRunner.Status): active (0), idle (1), offline (2).
|
||||
// The thresholds are evaluated against the current time, mirroring ToConds, so
|
||||
// sorting by status groups active and idle runners instead of interleaving them
|
||||
// by raw last_online.
|
||||
func runnerStatusOrderExpr() string {
|
||||
now := time.Now()
|
||||
offlineThreshold := now.Add(-RunnerOfflineTime).Unix()
|
||||
idleThreshold := now.Add(-RunnerIdleTime).Unix()
|
||||
return fmt.Sprintf("CASE WHEN last_online <= %d THEN 2 WHEN last_active <= %d THEN 1 ELSE 0 END", offlineThreshold, idleThreshold)
|
||||
}
|
||||
|
||||
func (opts FindRunnerOptions) ToOrders() string {
|
||||
// A unique tiebreaker (id) is appended so that runners sharing the same
|
||||
// last_online or name keep a deterministic order across paginated queries,
|
||||
// otherwise the same runner may appear on more than one page.
|
||||
// status, last_online or name keep a deterministic order across paginated
|
||||
// queries, otherwise the same runner may appear on more than one page.
|
||||
statusRank := runnerStatusOrderExpr()
|
||||
switch opts.Sort {
|
||||
case "online":
|
||||
return "last_online DESC, id ASC"
|
||||
// Rank by computed status first so idle runners are not interleaved with
|
||||
// active ones; disabled runners sink to the bottom of their status group
|
||||
// (is_disabled ASC), then last_online breaks ties within a group.
|
||||
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
|
||||
case "offline":
|
||||
return "last_online ASC, id ASC"
|
||||
return statusRank + " DESC, is_disabled ASC, last_online ASC, id ASC"
|
||||
case "alphabetically":
|
||||
return "name ASC, id ASC"
|
||||
case "reversealphabetically":
|
||||
@@ -288,7 +304,7 @@ func (opts FindRunnerOptions) ToOrders() string {
|
||||
case "oldest":
|
||||
return "id ASC"
|
||||
}
|
||||
return "last_online DESC, id ASC"
|
||||
return statusRank + " ASC, is_disabled ASC, last_online DESC, id ASC"
|
||||
}
|
||||
|
||||
// GetRunnerByUUID returns a runner via uuid
|
||||
|
||||
@@ -4,16 +4,12 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/timeutil"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShouldPersistLastOnline(t *testing.T) {
|
||||
@@ -85,65 +81,3 @@ func TestShouldPersistLastActive(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRunnerOptions_ToOrders_StableTiebreaker(t *testing.T) {
|
||||
// Sorts on a non-unique column must end with the unique id tiebreaker so
|
||||
// pagination is deterministic; without it, runners sharing the same
|
||||
// last_online or name can appear on more than one page. Sorts already on
|
||||
// the unique id need no tiebreaker.
|
||||
expected := map[string]string{
|
||||
"": "last_online DESC, id ASC",
|
||||
"online": "last_online DESC, id ASC",
|
||||
"offline": "last_online ASC, id ASC",
|
||||
"alphabetically": "name ASC, id ASC",
|
||||
"reversealphabetically": "name DESC, id ASC",
|
||||
"newest": "id DESC",
|
||||
"oldest": "id ASC",
|
||||
}
|
||||
for sort, want := range expected {
|
||||
assert.Equal(t, want, FindRunnerOptions{Sort: sort}.ToOrders(), "sort %q", sort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRunners_PaginationNoDuplicates(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
ctx := t.Context()
|
||||
|
||||
// Create several runners that all share the same last_online value so the
|
||||
// primary sort key (last_online) is tied for all of them.
|
||||
const ownerID = 1000
|
||||
const count = 6
|
||||
for i := range count {
|
||||
runner := &ActionRunner{
|
||||
Name: "paginated-runner",
|
||||
UUID: fmt.Sprintf("PAGINATE-TEST-0000-0000-00000000000%d", i),
|
||||
TokenHash: fmt.Sprintf("paginate-test-token-hash-%d", i),
|
||||
OwnerID: ownerID,
|
||||
RepoID: 0,
|
||||
LastOnline: 42,
|
||||
}
|
||||
require.NoError(t, db.Insert(ctx, runner))
|
||||
}
|
||||
|
||||
// Page through the runners and ensure every id is returned exactly once.
|
||||
seen := make(map[int64]int)
|
||||
const pageSize = 2
|
||||
for page := 1; ; page++ {
|
||||
runners, err := db.Find[ActionRunner](ctx, FindRunnerOptions{
|
||||
ListOptions: db.ListOptions{Page: page, PageSize: pageSize},
|
||||
OwnerID: ownerID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if len(runners) == 0 {
|
||||
break
|
||||
}
|
||||
for _, r := range runners {
|
||||
seen[r.ID]++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Len(t, seen, count, "each runner should be returned exactly once across all pages")
|
||||
for id, n := range seen {
|
||||
assert.Equal(t, 1, n, "runner %d appeared on %d pages", id, n)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-5
@@ -60,6 +60,12 @@ type ActionTask struct {
|
||||
Updated timeutil.TimeStamp `xorm:"updated index"`
|
||||
}
|
||||
|
||||
// taskReportTimeout is how long a task may go without contact from its runner before the
|
||||
// runner is assumed gone. Runners report state and stream logs every few seconds, both of
|
||||
// which refresh ActionTask.Updated. Shorter than setting.Actions.ZombieTaskTimeout because
|
||||
// it only decides whether the runner is reachable, not whether the task should be killed.
|
||||
const taskReportTimeout = time.Minute
|
||||
|
||||
var successfulTokenTaskCache *lru.Cache[string, any]
|
||||
|
||||
func init() {
|
||||
@@ -85,11 +91,15 @@ func (task *ActionTask) IsStopped() bool {
|
||||
return task.Stopped > 0
|
||||
}
|
||||
|
||||
func (task *ActionTask) GetRunLink() string {
|
||||
if task.Job == nil || task.Job.Run == nil {
|
||||
func (task *ActionTask) GetRunJobLink() string {
|
||||
// Run.Repo can be nil when the repository was deleted while task/run rows remain
|
||||
// (TaskList.LoadAttributes copies job.Repo into run.Repo, leaving it nil on a miss).
|
||||
// Run.Link() already returns "" in that case, so guard here to avoid emitting a
|
||||
// broken relative "/jobs/N" link from the Sprintf below.
|
||||
if task.Job == nil || task.Job.Run == nil || task.Job.Run.Repo == nil {
|
||||
return ""
|
||||
}
|
||||
return task.Job.Run.Link()
|
||||
return fmt.Sprintf("%s/jobs/%d", task.Job.Run.Link(), task.Job.ID)
|
||||
}
|
||||
|
||||
func (task *ActionTask) GetCommitLink() string {
|
||||
@@ -468,7 +478,7 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
return nil, err
|
||||
}
|
||||
task := &ActionTask{}
|
||||
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
|
||||
applyState := func(ctx context.Context) error {
|
||||
if has, err := db.GetEngine(ctx).ID(taskID).Get(task); err != nil {
|
||||
return err
|
||||
} else if !has {
|
||||
@@ -533,6 +543,10 @@ func UpdateTaskByState(ctx context.Context, runnerID int64, state *runnerv1.Task
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
err = globallock.LockAndDo(ctx, fmt.Sprintf("UpdateTaskByState-run-%d", runID), func(ctx context.Context) error {
|
||||
// A half-written report leaves the task done with a running job, which no retry repairs.
|
||||
return db.WithTx(ctx, applyState)
|
||||
})
|
||||
return task, err
|
||||
}
|
||||
@@ -563,6 +577,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
|
||||
status = StatusCancelled
|
||||
} else if !runner.HasCancellingSupport {
|
||||
status = StatusCancelled
|
||||
} else if task.Updated.AddDuration(taskReportTimeout) < now {
|
||||
// A runner that stopped reporting will never acknowledge the cancellation either,
|
||||
// so skip the handshake instead of waiting for the zombie task cleanup.
|
||||
status = StatusCancelled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +595,10 @@ func StopTask(ctx context.Context, taskID int64, status Status) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return UpdateTask(ctx, task, "status")
|
||||
// NoAutoTime keeps "updated" at the runner's last contact: re-cancelling an already
|
||||
// cancelling task must not defer the timeout above or the zombie task cleanup.
|
||||
_, err := e.ID(task.ID).Cols("status").NoAutoTime().Update(task)
|
||||
return err
|
||||
}
|
||||
|
||||
task.Status = status
|
||||
|
||||
+177
-171
@@ -4,11 +4,15 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
runnerv1 "gitea.dev/actions-proto-go/runner/v1"
|
||||
"gitea.dev/models/db"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
"gitea.dev/modules/actions/jobparser"
|
||||
"gitea.dev/modules/timeutil"
|
||||
@@ -16,8 +20,24 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"xorm.io/xorm/contexts"
|
||||
)
|
||||
|
||||
func TestActionTask_GetRunJobLink(t *testing.T) {
|
||||
repo := &repo_model.Repository{OwnerName: "org", Name: "consumer"}
|
||||
run := &ActionRun{ID: 10, Repo: repo}
|
||||
job := &ActionRunJob{ID: 42, Run: run}
|
||||
|
||||
// a task with a loaded job links to that specific job, not just the run
|
||||
task := &ActionTask{Job: job}
|
||||
assert.Equal(t, run.Link()+"/jobs/42", task.GetRunJobLink())
|
||||
|
||||
// missing job, run or repo yields an empty link instead of a broken URL
|
||||
assert.Empty(t, (&ActionTask{}).GetRunJobLink())
|
||||
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42}}).GetRunJobLink())
|
||||
assert.Empty(t, (&ActionTask{Job: &ActionRunJob{ID: 42, Run: &ActionRun{ID: 10}}}).GetRunJobLink())
|
||||
}
|
||||
|
||||
func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -83,68 +103,11 @@ func TestMakeTaskStepDisplayName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
|
||||
newRunningTask := func(t *testing.T) (*ActionTask, *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "cancelling-finalization-job",
|
||||
Attempt: 1,
|
||||
JobID: "cancelling-finalization-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-cancelling-supported",
|
||||
Name: "runner-cancelling-supported",
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.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(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
return task, job
|
||||
}
|
||||
|
||||
testResult := func(t *testing.T, result runnerv1.Result) {
|
||||
t.Helper()
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
task, job := newRunningTask(t)
|
||||
task, job := newRunningTaskForCancelling(t, "cancelling-finalization-job", true)
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
@@ -174,137 +137,92 @@ func TestTaskCancellingFinalizesToCancelled(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopTaskCancellingFallsBackForLegacyRunner(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
// TestStopTaskCancellingFallsBackToCancelled covers the cases where the cancelling handshake can
|
||||
// never complete, so StopTask must cancel right away instead of waiting for the zombie task cleanup.
|
||||
func TestStopTaskCancellingFallsBackToCancelled(t *testing.T) {
|
||||
assertCancelled := func(t *testing.T, task *ActionTask, job *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "legacy-cancelling-job",
|
||||
Attempt: 1,
|
||||
JobID: "legacy-cancelling-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
// A runner too old to know the cancelling state can only be stopped by a final status.
|
||||
t.Run("legacy runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "legacy-cancelling-job", false)
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-legacy-no-cancelling",
|
||||
Name: "runner-legacy-no-cancelling",
|
||||
HasCancellingSupport: false,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.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(t.Context(), task))
|
||||
// The runner is gone, e.g. an ephemeral runner was cleaned up, so nobody can acknowledge.
|
||||
t.Run("missing runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "missing-runner-cancelling-job", true)
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
_, err := db.DeleteByID[ActionRunner](t.Context(), task.RunnerID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
})
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
// The runner went silent, e.g. it gave up while Gitea was restarting, so it never picks up the request.
|
||||
t.Run("silent runner", func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "silent-runner-cancelling-job", true)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
// NoAutoTime because the point of the test is an "updated" older than xorm would write
|
||||
task.Updated = timeutil.TimeStampNow().AddDuration(-2 * taskReportTimeout)
|
||||
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
assertCancelled(t, task, job)
|
||||
|
||||
// A runner coming back still learns the outcome from the UpdateTask response,
|
||||
// and its late result does not overwrite the cancellation.
|
||||
late, err := UpdateTaskByState(t.Context(), task.RunnerID, &runnerv1.TaskState{
|
||||
Id: task.ID,
|
||||
Result: runnerv1.Result_RESULT_SUCCESS,
|
||||
StoppedAt: timestamppb.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusCancelled, late.Status)
|
||||
assert.Equal(t, runnerv1.Result_RESULT_CANCELLED, late.Status.AsResult())
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopTaskCancellingFallsBackForMissingRunner(t *testing.T) {
|
||||
// TestStopTaskCancellingKeepsReportTime makes sure persisting the cancelling status does not refresh
|
||||
// "updated": re-cancelling would otherwise defer both the fallback to cancelled and the zombie task
|
||||
// cleanup, no matter how long the runner has been silent.
|
||||
func TestStopTaskCancellingKeepsReportTime(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, _ := newRunningTaskForCancelling(t, "repeated-cancelling-job", true)
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: "missing-runner-cancelling-job",
|
||||
Attempt: 1,
|
||||
JobID: "missing-runner-cancelling-job",
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: "runner-cleaned-up-before-cancel",
|
||||
Name: "runner-cleaned-up-before-cancel",
|
||||
HasCancellingSupport: true,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.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(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.DeleteByID[ActionRunner](t.Context(), runner.ID)
|
||||
// NoAutoTime because the point of the test is an "updated" older than xorm would write
|
||||
lastReport := timeutil.TimeStampNow().AddDuration(-taskReportTimeout / 2)
|
||||
task.Updated = lastReport
|
||||
_, err := db.GetEngine(t.Context()).ID(task.ID).Cols("updated").NoAutoTime().Update(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
// the runner reported recently enough, so the task waits for it to acknowledge
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
|
||||
taskAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelled, taskAfterStop.Status)
|
||||
assert.NotZero(t, taskAfterStop.Stopped)
|
||||
assert.Equal(t, StatusCancelling, taskAfterStop.Status)
|
||||
assert.Equal(t, lastReport, taskAfterStop.Updated)
|
||||
|
||||
jobAfterStop := unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID})
|
||||
assert.Equal(t, StatusCancelled, jobAfterStop.Status)
|
||||
assert.NotZero(t, jobAfterStop.Stopped)
|
||||
// cancelling it again does not reset the clock either
|
||||
require.NoError(t, StopTask(t.Context(), task.ID, StatusCancelling))
|
||||
taskAfterSecondStop := unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID})
|
||||
assert.Equal(t, StatusCancelling, taskAfterSecondStop.Status)
|
||||
assert.Equal(t, lastReport, taskAfterSecondStop.Updated)
|
||||
}
|
||||
|
||||
// TestReleaseTaskForRunner verifies that releasing a freshly-claimed task returns
|
||||
@@ -442,3 +360,91 @@ func TestCreateTaskForRunnerPagination(t *testing.T) {
|
||||
assert.Equal(t, StatusRunning, claimed.Status)
|
||||
assert.Equal(t, task.ID, claimed.TaskID)
|
||||
}
|
||||
|
||||
type failFirstStepWrite struct{ fired atomic.Bool }
|
||||
|
||||
func (h *failFirstStepWrite) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
|
||||
if !h.fired.Load() && strings.HasPrefix(c.SQL, "UPDATE") && strings.Contains(c.SQL, "action_task_step") {
|
||||
h.fired.Store(true)
|
||||
return nil, errors.New("interrupted")
|
||||
}
|
||||
return c.Ctx, nil
|
||||
}
|
||||
|
||||
func (*failFirstStepWrite) AfterProcess(*contexts.ContextHook) error { return nil }
|
||||
|
||||
// TestUpdateTaskByStateIsAtomic checks that an interrupted report writes nothing: a surviving task or
|
||||
// job write would hit the "state is final" early return, which no retry or cleanup repairs.
|
||||
func TestUpdateTaskByStateIsAtomic(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
task, job := newRunningTaskForCancelling(t, "atomic-report-job", true)
|
||||
require.NoError(t, db.Insert(t.Context(), &ActionTaskStep{TaskID: task.ID, RepoID: task.RepoID, Status: StatusRunning}))
|
||||
unittest.GetXORMEngine().AddHook(&failFirstStepWrite{})
|
||||
finalState := &runnerv1.TaskState{Id: task.ID, Result: runnerv1.Result_RESULT_SUCCESS, StoppedAt: timestamppb.Now()}
|
||||
_, err := UpdateTaskByState(t.Context(), task.RunnerID, finalState)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionTask{ID: task.ID}).Status)
|
||||
assert.Equal(t, StatusRunning, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
|
||||
_, err = UpdateTaskByState(t.Context(), task.RunnerID, finalState)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusSuccess, unittest.AssertExistsAndLoadBean(t, &ActionRunJob{ID: job.ID}).Status)
|
||||
}
|
||||
|
||||
// newRunningTaskForCancelling inserts a running run/job/task assigned to a fresh runner,
|
||||
// which is the state every cancellation test starts from.
|
||||
func newRunningTaskForCancelling(t *testing.T, name string, hasCancellingSupport bool) (*ActionTask, *ActionRunJob) {
|
||||
t.Helper()
|
||||
|
||||
run := &ActionRun{
|
||||
Title: "cancelling-test-run",
|
||||
RepoID: 1,
|
||||
OwnerID: 2,
|
||||
WorkflowID: "test.yaml",
|
||||
Index: 999,
|
||||
TriggerUserID: 2,
|
||||
Ref: "refs/heads/master",
|
||||
CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0",
|
||||
Event: "push",
|
||||
TriggerEvent: "push",
|
||||
Status: StatusRunning,
|
||||
Started: timeutil.TimeStampNow(),
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), run))
|
||||
|
||||
job := &ActionRunJob{
|
||||
RunID: run.ID,
|
||||
RepoID: run.RepoID,
|
||||
OwnerID: run.OwnerID,
|
||||
CommitSHA: run.CommitSHA,
|
||||
Name: name,
|
||||
Attempt: 1,
|
||||
JobID: name,
|
||||
Status: StatusRunning,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), job))
|
||||
|
||||
runner := &ActionRunner{
|
||||
UUID: name,
|
||||
Name: name,
|
||||
HasCancellingSupport: hasCancellingSupport,
|
||||
}
|
||||
require.NoError(t, db.Insert(t.Context(), runner))
|
||||
|
||||
task := &ActionTask{
|
||||
JobID: job.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(t.Context(), task))
|
||||
|
||||
job.TaskID = task.ID
|
||||
_, err := UpdateRunJob(t.Context(), job, nil, "task_id")
|
||||
require.NoError(t, err)
|
||||
|
||||
return task, job
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
|
||||
DisplayName: "tea",
|
||||
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
|
||||
}
|
||||
m["b757811a-05c8-4c76-8d74-a5ee3d2073f2"] = &BuiltinOAuth2Application{
|
||||
ConfigName: "gitea-app",
|
||||
DisplayName: "Gitea App",
|
||||
RedirectURIs: []string{"com.gitea.app://oauth/callback"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
|
||||
"gitea.dev/models/user"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
@@ -33,11 +34,18 @@ func BuildAvatarStackData(ctx context.Context, allParticipants []*git.CommitIden
|
||||
ret := &AvatarStackData{
|
||||
Participants: make([]*CommitParticipant, 0, len(allParticipants)),
|
||||
}
|
||||
uniqueUserIDs := make(container.Set[int64])
|
||||
for _, p := range allParticipants {
|
||||
var giteaUser *user.User
|
||||
if emailUserMap != nil {
|
||||
giteaUser = emailUserMap.GetByEmail(p.Email)
|
||||
}
|
||||
if giteaUser != nil {
|
||||
// identities without a Gitea account can only be compared by their git identity
|
||||
if !uniqueUserIDs.Add(giteaUser.ID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
ret.Participants = append(ret.Participants, &CommitParticipant{GiteaUser: giteaUser, GitIdentity: p})
|
||||
}
|
||||
return ret
|
||||
|
||||
@@ -39,8 +39,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
|
||||
emailSet := make(container.Set[string])
|
||||
for _, c := range gitCommits {
|
||||
emailSet.Add(c.Author.Email)
|
||||
emailSet.Add(c.Committer.Email)
|
||||
for _, p := range c.AllParticipantIdentities() {
|
||||
for _, p := range c.AllAuthorIdentities() {
|
||||
emailSet.Add(p.Email)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +54,7 @@ func GetUserCommitsByGitCommits(ctx context.Context, gitCommits []*git.Commit, r
|
||||
uc := &UserCommit{
|
||||
AuthorUser: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why GetUserCommitsByGitCommits uses "Author", but ParseCommitsWithSignature uses "Committer"?
|
||||
GitCommit: c,
|
||||
AvatarStackData: BuildAvatarStackData(ctx, c.AllParticipantIdentities(), emailUserMap),
|
||||
AvatarStackData: BuildAvatarStackData(ctx, c.AllAuthorIdentities(), emailUserMap),
|
||||
}
|
||||
uc.AvatarStackData.SearchByEmailLink = searchByEmailLink
|
||||
userCommits = append(userCommits, uc)
|
||||
|
||||
@@ -544,6 +544,9 @@ func (c *Comment) GetSanitizedContentHTML() template.HTML {
|
||||
|
||||
// LoadLabel if comment.Type is CommentTypeLabel, then load Label
|
||||
func (c *Comment) LoadLabel(ctx context.Context) error {
|
||||
if c.LabelID == 0 {
|
||||
return nil
|
||||
}
|
||||
var label Label
|
||||
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
|
||||
if err != nil {
|
||||
@@ -551,8 +554,8 @@ func (c *Comment) LoadLabel(ctx context.Context) error {
|
||||
} else if has {
|
||||
c.Label = &label
|
||||
} else {
|
||||
// Ignore Label is deleted, but not clear this table
|
||||
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
|
||||
// label was deleted but comment rows referencing it were not cleaned up
|
||||
log.Debug("Comment %d references deleted label %d", c.ID, c.LabelID)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -81,7 +81,7 @@ func (comments CommentList) loadLabels(ctx context.Context) error {
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
comment.Label = commentLabels[comment.ID]
|
||||
comment.Label = commentLabels[comment.LabelID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ func applyRepoConditions(sess db.Session, opts *IssuesOptions) {
|
||||
if opts.RepoCond == nil {
|
||||
opts.RepoCond = builder.NewCond()
|
||||
}
|
||||
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"is_private": false})))
|
||||
opts.RepoCond = opts.RepoCond.Or(builder.In("issue.repo_id", builder.Select("id").From("repository").Where(repo_model.PublicRepoUnderPublicOwnerCond())))
|
||||
}
|
||||
if opts.RepoCond != nil {
|
||||
sess.And(opts.RepoCond)
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
package issues
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -191,6 +193,42 @@ func (l *Label) ExclusiveScope() string {
|
||||
return l.Name[:lastIndex]
|
||||
}
|
||||
|
||||
// CompareLabelForDisplay compares labels for displaying them in dropdowns or lists.
|
||||
// Labels are grouped by their exclusive scope, and labels within the same scope
|
||||
// are sorted by their exclusive order, where unordered labels (order 0) come last.
|
||||
// Labels without a scope are listed first and everything else falls back to name order.
|
||||
func CompareLabelForDisplay(a, b *Label) int {
|
||||
scopeA, scopeB := a.ExclusiveScope(), b.ExclusiveScope()
|
||||
if scopeA != scopeB {
|
||||
if scopeA == "" {
|
||||
return -1
|
||||
}
|
||||
if scopeB == "" {
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(scopeA, scopeB)
|
||||
}
|
||||
if scopeA != "" {
|
||||
orderA, orderB := a.ExclusiveOrder, b.ExclusiveOrder
|
||||
if orderA <= 0 {
|
||||
orderA = math.MaxInt
|
||||
}
|
||||
if orderB <= 0 {
|
||||
orderB = math.MaxInt
|
||||
}
|
||||
if orderA != orderB {
|
||||
return cmp.Compare(orderA, orderB)
|
||||
}
|
||||
}
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
}
|
||||
|
||||
// SortLabelsForDisplay sorts labels in place for displaying them in dropdowns or lists,
|
||||
// grouping them by their exclusive scope and respecting the exclusive order within each scope.
|
||||
func SortLabelsForDisplay(labels []*Label) {
|
||||
slices.SortStableFunc(labels, CompareLabelForDisplay)
|
||||
}
|
||||
|
||||
// NewLabel creates a new label
|
||||
func NewLabel(ctx context.Context, l *Label) error {
|
||||
color, err := label.NormalizeColor(l.Color)
|
||||
|
||||
@@ -54,6 +54,47 @@ func TestLabel_ExclusiveScope(t *testing.T) {
|
||||
assert.Equal(t, "scope/subscope", label.ExclusiveScope())
|
||||
}
|
||||
|
||||
func TestSortLabelsForDisplay(t *testing.T) {
|
||||
labels := []*issues_model.Label{
|
||||
{Name: "priority/low", Exclusive: true, ExclusiveOrder: 4},
|
||||
{Name: "priority/critical", Exclusive: true, ExclusiveOrder: 1},
|
||||
{Name: "priority/medium", Exclusive: true, ExclusiveOrder: 3},
|
||||
{Name: "priority/high", Exclusive: true, ExclusiveOrder: 2},
|
||||
{Name: "bug"},
|
||||
{Name: "enhancement"},
|
||||
{Name: "kind/question", Exclusive: true},
|
||||
}
|
||||
issues_model.SortLabelsForDisplay(labels)
|
||||
|
||||
names := make([]string, 0, len(labels))
|
||||
for _, l := range labels {
|
||||
names = append(names, l.Name)
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"bug",
|
||||
"enhancement",
|
||||
"kind/question",
|
||||
"priority/critical",
|
||||
"priority/high",
|
||||
"priority/medium",
|
||||
"priority/low",
|
||||
}, names)
|
||||
|
||||
// labels without an exclusive order in the same scope are listed last, ordered by name
|
||||
labels = []*issues_model.Label{
|
||||
{Name: "scope/unordered-b", Exclusive: true},
|
||||
{Name: "scope/ordered", Exclusive: true, ExclusiveOrder: 1},
|
||||
{Name: "scope/unordered-a", Exclusive: true},
|
||||
}
|
||||
issues_model.SortLabelsForDisplay(labels)
|
||||
|
||||
names = names[:0]
|
||||
for _, l := range labels {
|
||||
names = append(names, l.Name)
|
||||
}
|
||||
assert.Equal(t, []string{"scope/ordered", "scope/unordered-a", "scope/unordered-b"}, names)
|
||||
}
|
||||
|
||||
func TestNewLabels(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
labels := []*issues_model.Label{
|
||||
|
||||
+13
-9
@@ -1065,19 +1065,23 @@ func (r *Review) GetCodeCommentsCount(ctx context.Context) int {
|
||||
return int(count)
|
||||
}
|
||||
|
||||
// HTMLURL formats a URL-string to the related review issue-comment
|
||||
// HashTag returns unique hash tag for review.
|
||||
func (r *Review) HashTag() string {
|
||||
return fmt.Sprintf("pullrequestreview-%d", r.ID)
|
||||
}
|
||||
|
||||
// HTMLURL formats a URL-string to the review on the pull request page
|
||||
func (r *Review) HTMLURL(ctx context.Context) string {
|
||||
opts := FindCommentsOptions{
|
||||
Type: CommentTypeReview,
|
||||
IssueID: r.IssueID,
|
||||
ReviewID: r.ID,
|
||||
if r.Type != ReviewTypeApprove && r.Type != ReviewTypeComment && r.Type != ReviewTypeReject {
|
||||
return "" // only submitted reviews get a timeline block carrying the anchor
|
||||
}
|
||||
comment := new(Comment)
|
||||
has, err := db.GetEngine(ctx).Where(opts.ToConds()).Get(comment)
|
||||
if err != nil || !has {
|
||||
if err := r.LoadIssue(ctx); err != nil {
|
||||
return ""
|
||||
}
|
||||
return comment.HTMLURL(ctx)
|
||||
if err := r.Issue.LoadRepo(ctx); err != nil {
|
||||
return ""
|
||||
}
|
||||
return r.Issue.HTMLURL(ctx) + "#" + r.HashTag()
|
||||
}
|
||||
|
||||
// RemapExternalUser ExternalUserRemappable interface
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -28,6 +29,15 @@ func TestGetReviewByID(t *testing.T) {
|
||||
assert.True(t, issues_model.IsErrReviewNotExist(err), "IsErrReviewNotExist")
|
||||
}
|
||||
|
||||
func TestReview_HTMLURL(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1})
|
||||
assert.Equal(t, setting.AppURL+"user2/repo1/pulls/2#pullrequestreview-1", review.HTMLURL(t.Context()))
|
||||
|
||||
pendingReview := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 4})
|
||||
assert.Empty(t, pendingReview.HTMLURL(t.Context()))
|
||||
}
|
||||
|
||||
func TestReview_LoadAttributes(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1})
|
||||
|
||||
@@ -303,7 +303,7 @@ func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.Us
|
||||
}
|
||||
}
|
||||
|
||||
if org.Visibility.IsPublic() {
|
||||
if ownerVisibilitySatisfiesDoer(org.AsUser(), doer) {
|
||||
return perm.AccessModeRead
|
||||
}
|
||||
|
||||
@@ -445,8 +445,7 @@ func GetUsersWhoCanCreateOrgRepo(ctx context.Context, orgID int64) (map[int64]*u
|
||||
And("team_user.org_id = ?", orgID).Find(&users)
|
||||
}
|
||||
|
||||
// HasOrgOrUserVisible tells if the given user can see the given org or user
|
||||
func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User) bool {
|
||||
func ownerVisibilitySatisfiesDoer(orgOrUser, user *user_model.User) bool {
|
||||
// If user is nil, it's an anonymous user/request.
|
||||
// The Ghost user is handled like an anonymous user.
|
||||
if user == nil || user.IsGhost() {
|
||||
@@ -461,10 +460,13 @@ func HasOrgOrUserVisible(ctx context.Context, orgOrUser, user *user_model.User)
|
||||
return true
|
||||
}
|
||||
|
||||
if (orgOrUser.Visibility == structs.VisibleTypePrivate || user.IsRestricted) && !OrgFromUser(orgOrUser).hasMemberWithUserID(ctx, user.ID) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
return orgOrUser.Visibility != structs.VisibleTypePrivate && !user.IsRestricted
|
||||
}
|
||||
|
||||
// HasOrgOrUserVisible tells if the given user can see the given org or user
|
||||
func HasOrgOrUserVisible(ctx context.Context, owner, doer *user_model.User) bool {
|
||||
return ownerVisibilitySatisfiesDoer(owner, doer) ||
|
||||
(doer != nil && OrgFromUser(owner).HasMemberWithUserID(ctx, doer.ID))
|
||||
}
|
||||
|
||||
// HasOrgsVisible tells if the given user can see at least one of the orgs provided
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/organization"
|
||||
"gitea.dev/models/perm"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/setting"
|
||||
@@ -626,3 +628,10 @@ func TestCreateOrganization4(t *testing.T) {
|
||||
assert.True(t, db.IsErrNameReserved(err))
|
||||
unittest.CheckConsistencyFor(t, &organization.Organization{}, &organization.Team{})
|
||||
}
|
||||
|
||||
func TestOrAnyRepoUnitPermission(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)()
|
||||
org := organization.Organization{Visibility: structs.VisibleTypeLimited}
|
||||
assert.Equal(t, perm.AccessModeNone, org.UnitPermission(t.Context(), nil, unit.TypeWiki))
|
||||
assert.Equal(t, perm.AccessModeRead, org.UnitPermission(t.Context(), &user_model.User{}, unit.TypeWiki))
|
||||
}
|
||||
|
||||
@@ -122,6 +122,18 @@ func TestGetActionsUserRepoPermission(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.False(t, perm.CanRead(unit.TypeCode))
|
||||
|
||||
// Reusable workflows use a separate authorization path and must enforce
|
||||
// the same fork-PR restriction.
|
||||
run := &actions_model.ActionRun{RepoID: repo2.ID, IsForkPullRequest: true}
|
||||
allowed, err := CanReadWorkflowCrossRepo(ctx, repo15, run)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
|
||||
run.IsForkPullRequest = false
|
||||
allowed, err = CanReadWorkflowCrossRepo(ctx, repo15, run)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
|
||||
// Restore state for subsequent subtests.
|
||||
task53.IsForkPullRequest = false
|
||||
require.NoError(t, actions_model.UpdateTask(ctx, task53, "is_fork_pull_request"))
|
||||
|
||||
@@ -675,7 +675,7 @@ func CanReadWorkflowCrossRepo(ctx context.Context, targetRepo *repo_model.Reposi
|
||||
// logs in a publicly visible run; requiring a private caller keeps private content flowing private -> private.
|
||||
// This is intentionally stricter than GitHub, which gates on the target repo's access setting (introduced in #32562):
|
||||
// https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository#allowing-access-to-components-in-a-private-repository
|
||||
if run.Repo.IsPrivate {
|
||||
if run.Repo.IsPrivate && !run.IsForkPullRequest {
|
||||
if actionsUnit, err := targetRepo.GetUnit(ctx, unit.TypeActions); err == nil {
|
||||
if actionsUnit.ActionsConfig().IsCollaborativeOwner(run.Repo.OwnerID) {
|
||||
return true, nil
|
||||
|
||||
@@ -7,41 +7,45 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/gitrepo"
|
||||
"gitea.dev/modules/log"
|
||||
)
|
||||
|
||||
type commitChecker struct {
|
||||
ctx context.Context
|
||||
commitCache map[string]bool
|
||||
gitRepoFacade gitrepo.Repository
|
||||
ctx context.Context
|
||||
commitCache map[string]bool
|
||||
repoOptional *repo_model.Repository
|
||||
|
||||
gitRepo *git.Repository
|
||||
gitRepoCloser io.Closer
|
||||
}
|
||||
|
||||
func newCommitChecker(ctx context.Context, gitRepo gitrepo.Repository) *commitChecker {
|
||||
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), gitRepoFacade: gitRepo}
|
||||
func newCommitChecker(ctx context.Context, repo *repo_model.Repository) *commitChecker {
|
||||
return &commitChecker{ctx: ctx, commitCache: make(map[string]bool), repoOptional: repo}
|
||||
}
|
||||
|
||||
func (c *commitChecker) Close() error {
|
||||
if c != nil && c.gitRepoCloser != nil {
|
||||
if c.gitRepoCloser != nil {
|
||||
return c.gitRepoCloser.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *commitChecker) IsCommitIDExisting(commitID string) bool {
|
||||
if c.repoOptional == nil {
|
||||
return false
|
||||
}
|
||||
exist, inCache := c.commitCache[commitID]
|
||||
if inCache {
|
||||
return exist
|
||||
}
|
||||
|
||||
if c.gitRepo == nil {
|
||||
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.gitRepoFacade)
|
||||
r, closer, err := gitrepo.RepositoryFromContextOrOpen(c.ctx, c.repoOptional)
|
||||
if err != nil {
|
||||
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.gitRepoFacade), err)
|
||||
log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(c.repoOptional), err)
|
||||
return false
|
||||
}
|
||||
c.gitRepo, c.gitRepoCloser = r, closer
|
||||
|
||||
@@ -51,10 +51,10 @@ func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repositor
|
||||
helper := &RepoComment{opts: util.OptionalArg(opts)}
|
||||
rctx := markup.NewRenderContext(ctx)
|
||||
helper.ctx = rctx
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
var metas map[string]string
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
metas = repo.ComposeCommentMetas(ctx)
|
||||
} else {
|
||||
// repo can be nil when rendering a commit message in user's dashboard feedback whose repository has been deleted
|
||||
|
||||
@@ -35,11 +35,11 @@ func (r *RepoFile) ResolveLink(link, preferLinkType string) (finalLink string) {
|
||||
case markup.LinkTypeRoot:
|
||||
finalLink = r.ctx.ResolveLinkRoot(link)
|
||||
case markup.LinkTypeRaw:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "raw", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||
case markup.LinkTypeMedia:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "media", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||
default:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), r.opts.CurrentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "src", r.opts.CurrentRefSubURL), util.PathEscapeSegments(r.opts.CurrentTreePath), link)
|
||||
}
|
||||
return finalLink
|
||||
}
|
||||
@@ -58,9 +58,9 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository,
|
||||
helper := &RepoFile{opts: util.OptionalArg(opts)}
|
||||
rctx := markup.NewRenderContext(ctx)
|
||||
helper.ctx = rctx
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
rctx = rctx.WithMetas(repo.ComposeRepoFileMetas(ctx))
|
||||
} else {
|
||||
// this is almost dead code, only to pass the incorrect tests
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestRepoFile(t *testing.T) {
|
||||
t.Run("WithCurrentRefSubURLByTag", func(t *testing.T) {
|
||||
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
||||
CurrentRefSubURL: "/commit/1234",
|
||||
CurrentTreePath: "my-dir",
|
||||
CurrentTreePath: "my dir",
|
||||
}).
|
||||
WithMarkupType(markdown.MarkupName)
|
||||
rendered, err := testRenderString(rctx, `
|
||||
@@ -76,8 +76,8 @@ func TestRepoFile(t *testing.T) {
|
||||
<video src="LINK">
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my-dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my-dir/LINK"/></a>
|
||||
<video src="/user2/repo1/media/commit/1234/my-dir/LINK">
|
||||
assert.Equal(t, `<a href="/user2/repo1/src/commit/1234/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/media/commit/1234/my%20dir/LINK"/></a>
|
||||
<video src="/user2/repo1/media/commit/1234/my%20dir/LINK">
|
||||
</video>`, rendered)
|
||||
})
|
||||
}
|
||||
@@ -89,8 +89,8 @@ func TestRepoFileOrgMode(t *testing.T) {
|
||||
t.Run("Links", func(t *testing.T) {
|
||||
rctx := NewRenderContextRepoFile(t.Context(), repo1, RepoFileOptions{
|
||||
CurrentRefSubURL: "/commit/1234",
|
||||
CurrentTreePath: "my-dir",
|
||||
}).WithRelativePath("my-dir/a.org")
|
||||
CurrentTreePath: "my dir",
|
||||
}).WithRelativePath("my dir/a.org")
|
||||
|
||||
rendered, err := testRenderString(rctx, `
|
||||
[[https://google.com/]]
|
||||
@@ -99,7 +99,7 @@ func TestRepoFileOrgMode(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<p>
|
||||
<a href="https://google.com/" rel="nofollow">https://google.com/</a>
|
||||
<a href="/user2/repo1/src/commit/1234/my-dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
|
||||
<a href="/user2/repo1/src/commit/1234/my%20dir/ImageLink.svg" rel="nofollow">The Image Desc</a></p>
|
||||
`, rendered)
|
||||
})
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ func (r *RepoWiki) ResolveLink(link, preferLinkType string) (finalLink string) {
|
||||
case markup.LinkTypeRoot:
|
||||
finalLink = r.ctx.ResolveLinkRoot(link)
|
||||
case markup.LinkTypeMedia, markup.LinkTypeRaw:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), r.opts.currentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki/raw", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
|
||||
default:
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), r.opts.currentTreePath, link)
|
||||
finalLink = r.ctx.ResolveLinkRelative(path.Join(r.repoLink, "wiki", r.opts.currentRefSubURL), util.PathEscapeSegments(r.opts.currentTreePath), link)
|
||||
}
|
||||
return finalLink
|
||||
}
|
||||
@@ -57,9 +57,9 @@ type RepoWikiOptions struct {
|
||||
func NewRenderContextRepoWiki(ctx context.Context, repo *repo_model.Repository, opts ...RepoWikiOptions) *markup.RenderContext {
|
||||
helper := &RepoWiki{opts: util.OptionalArg(opts)}
|
||||
rctx := markup.NewRenderContext(ctx).WithMarkupType(markdown.MarkupName)
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
if repo != nil {
|
||||
helper.repoLink = repo.Link()
|
||||
helper.commitChecker = newCommitChecker(ctx, repo)
|
||||
rctx = rctx.WithMetas(repo.ComposeWikiMetas(ctx))
|
||||
} else {
|
||||
// this is almost dead code, only to pass the incorrect tests
|
||||
|
||||
@@ -50,14 +50,16 @@ func TestRepoWiki(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("PathInTag", func(t *testing.T) {
|
||||
rctx := NewRenderContextRepoWiki(t.Context(), repo1).WithMarkupType(markdown.MarkupName)
|
||||
rctx := NewRenderContextRepoWiki(t.Context(), repo1, RepoWikiOptions{
|
||||
currentTreePath: "my dir",
|
||||
}).WithMarkupType(markdown.MarkupName)
|
||||
rendered, err := testRenderString(rctx, `
|
||||
<img src="LINK">
|
||||
<video src="LINK">
|
||||
`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<a href="/user2/repo1/wiki/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/LINK"/></a>
|
||||
<video src="/user2/repo1/wiki/raw/LINK">
|
||||
assert.Equal(t, `<a href="/user2/repo1/wiki/my%20dir/LINK" target="_blank" rel="nofollow noopener"><img src="/user2/repo1/wiki/raw/my%20dir/LINK"/></a>
|
||||
<video src="/user2/repo1/wiki/raw/my%20dir/LINK">
|
||||
</video>`, rendered)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,39 +111,6 @@ func IsCollaborator(ctx context.Context, repoID, userID int64) (bool, error) {
|
||||
return db.Exist[Collaboration](ctx, builder.Eq{"repo_id": repoID, "user_id": userID})
|
||||
}
|
||||
|
||||
// ChangeCollaborationAccessMode sets new access mode for the collaboration.
|
||||
func ChangeCollaborationAccessMode(ctx context.Context, repo *Repository, uid int64, mode perm.AccessMode) error {
|
||||
// Discard invalid input
|
||||
if mode <= perm.AccessModeNone || mode > perm.AccessModeOwner {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
collaboration, has, err := db.Get[Collaboration](ctx, builder.Eq{"repo_id": repo.ID, "user_id": uid})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get collaboration: %w", err)
|
||||
} else if !has {
|
||||
return nil
|
||||
}
|
||||
|
||||
if collaboration.Mode == mode {
|
||||
return nil
|
||||
}
|
||||
collaboration.Mode = mode
|
||||
|
||||
if _, err = db.GetEngine(ctx).
|
||||
ID(collaboration.ID).
|
||||
Cols("mode").
|
||||
Update(collaboration); err != nil {
|
||||
return fmt.Errorf("update collaboration: %w", err)
|
||||
} else if _, err = db.Exec(ctx, "UPDATE access SET mode = ? WHERE user_id = ? AND repo_id = ?", mode, uid, repo.ID); err != nil {
|
||||
return fmt.Errorf("update access table: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// IsOwnerMemberCollaborator checks if a provided user is the owner, a collaborator or a member of a team in a repository
|
||||
func IsOwnerMemberCollaborator(ctx context.Context, repo *Repository, userID int64) (bool, error) {
|
||||
if repo.OwnerID == userID {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/perm"
|
||||
access_model "gitea.dev/models/perm/access"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
|
||||
@@ -69,28 +67,6 @@ func TestRepository_IsCollaborator(t *testing.T) {
|
||||
test(4, 4, true)
|
||||
}
|
||||
|
||||
func TestRepository_ChangeCollaborationAccessMode(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
|
||||
|
||||
collaboration := unittest.AssertExistsAndLoadBean(t, &repo_model.Collaboration{RepoID: repo.ID, UserID: 4})
|
||||
assert.Equal(t, perm.AccessModeAdmin, collaboration.Mode)
|
||||
|
||||
access := unittest.AssertExistsAndLoadBean(t, &access_model.Access{UserID: 4, RepoID: repo.ID})
|
||||
assert.Equal(t, perm.AccessModeAdmin, access.Mode)
|
||||
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessModeAdmin))
|
||||
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, unittest.NonexistentID, perm.AccessModeAdmin))
|
||||
|
||||
// Discard invalid input.
|
||||
assert.NoError(t, repo_model.ChangeCollaborationAccessMode(t.Context(), repo, 4, perm.AccessMode(-1)))
|
||||
|
||||
unittest.CheckConsistencyFor(t, &repo_model.Repository{ID: repo.ID})
|
||||
}
|
||||
|
||||
func TestRepository_IsOwnerMemberCollaborator(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
|
||||
@@ -655,14 +655,12 @@ func SearchRepositoryIDsByCondition(ctx context.Context, cond builder.Cond) ([]i
|
||||
Find(&repoIDs)
|
||||
}
|
||||
|
||||
func userAllPublicRepoCond(cond builder.Cond, orgVisibilityLimit []structs.VisibleType) builder.Cond {
|
||||
func userAllPublicRepoCond(cond builder.Cond, ownerVisibilityLimit []structs.VisibleType) builder.Cond {
|
||||
return cond.Or(builder.And(
|
||||
builder.Eq{"`repository`.is_private": false},
|
||||
// Aren't in a private organisation or limited organisation if we're not logged in
|
||||
// Exclude owners who are not visible to the caller.
|
||||
builder.NotIn("`repository`.owner_id", builder.Select("id").From("`user`").Where(
|
||||
builder.And(
|
||||
builder.Eq{"type": user_model.UserTypeOrganization},
|
||||
builder.In("visibility", orgVisibilityLimit)),
|
||||
builder.In("visibility", ownerVisibilityLimit),
|
||||
))))
|
||||
}
|
||||
|
||||
@@ -771,6 +769,15 @@ func PublicRepoUnderPublicOwnerCond() builder.Cond {
|
||||
)
|
||||
}
|
||||
|
||||
// NotPublicRepoUnderPublicOwnerCond complements PublicRepoUnderPublicOwnerCond. Spelled positively so
|
||||
// the owner subquery hashes the limited/private minority, not every public user.
|
||||
func NotPublicRepoUnderPublicOwnerCond() builder.Cond {
|
||||
return builder.Or(
|
||||
builder.Eq{"`repository`.is_private": true},
|
||||
builder.In("`repository`.owner_id", builder.Select("id").From("`user`").Where(builder.Neq{"visibility": structs.VisibleTypePublic})),
|
||||
)
|
||||
}
|
||||
|
||||
// UserActionsAccessibleOwnerRepoCond selects the repos owned by ownerID whose Actions `user` may read.
|
||||
// It is used to list an org/user's Actions runs and jobs (see the callers in routers/api/v1/shared).
|
||||
// - owner_id = ownerID: only that owner's repos.
|
||||
|
||||
@@ -271,6 +271,23 @@ func testSearchRepositoryRestricted(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchRepositoryExcludesHiddenIndividualOwners(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
hiddenOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
require.NoError(t, user_model.UpdateUserCols(t.Context(), &user_model.User{
|
||||
ID: hiddenOwner.ID,
|
||||
Visibility: structs.VisibleTypePrivate,
|
||||
}, "visibility"))
|
||||
|
||||
repos, _, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
|
||||
ListOptions: db.ListOptions{Page: 1, PageSize: 100},
|
||||
Keyword: "repo1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, repoIDs(repos), int64(1))
|
||||
}
|
||||
|
||||
func testSearchRepositoryPrivate(t *testing.T) {
|
||||
// test search private repository on explore page
|
||||
repos, count, err := repo_model.SearchRepositoryByName(t.Context(), repo_model.SearchRepoOptions{
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// maxExpandedNodes bounds how many nodes alias expansion may create. go-yaml's own alias guard does
|
||||
// not cover us: it only counts while decoding into values, and a workflow is kept as raw yaml.Nodes.
|
||||
const maxExpandedNodes = 50000
|
||||
|
||||
var errTooManyYamlNodes = errors.New("maximum YAML nodes exceeded")
|
||||
|
||||
// ReadWorkflow decodes a workflow file with its aliases expanded. Callers inspect the workflow's
|
||||
// raw nodes by kind, and an alias is a kind none of them expect.
|
||||
func ReadWorkflow(content []byte) (*model.Workflow, error) {
|
||||
doc, err := resolveYamlAliases(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readWorkflowDoc(doc)
|
||||
}
|
||||
|
||||
func readWorkflowDoc(doc *yaml.Node) (*model.Workflow, error) {
|
||||
if doc.Kind == 0 {
|
||||
return nil, io.EOF // what a yaml decoder reports for an empty file
|
||||
}
|
||||
w := new(model.Workflow)
|
||||
return w, doc.Decode(w)
|
||||
}
|
||||
|
||||
// decodeResolved is yaml.Unmarshal with aliases expanded first.
|
||||
func decodeResolved(content []byte, out any) error {
|
||||
doc, err := resolveYamlAliases(content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return decodeYamlDoc(doc, out)
|
||||
}
|
||||
|
||||
func decodeYamlDoc(doc *yaml.Node, out any) error {
|
||||
if doc.Kind == 0 {
|
||||
return nil // an empty document, as yaml.Unmarshal treats it
|
||||
}
|
||||
return doc.Decode(out)
|
||||
}
|
||||
|
||||
// resolveYamlAliases parses content and replaces every alias with a copy of the node its anchor names.
|
||||
func resolveYamlAliases(content []byte) (*yaml.Node, error) {
|
||||
doc := &yaml.Node{}
|
||||
if err := yaml.Unmarshal(content, doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
budget := maxExpandedNodes
|
||||
return doc, expandAliases(doc, &budget)
|
||||
}
|
||||
|
||||
// expandAliases replaces node's alias descendants in place.
|
||||
func expandAliases(node *yaml.Node, budget *int) error {
|
||||
node.Anchor = "" // a name for a node, not part of the workflow: keep it out of the payloads
|
||||
if err := rejectMergeKeys(node); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, child := range node.Content {
|
||||
if child.Kind != yaml.AliasNode {
|
||||
if err := expandAliases(child, budget); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
copied, err := copyExpanded(child.Alias, budget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node.Content[i] = copied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyExpanded deep copies a node expandAliases already expanded and validated, since an anchor is
|
||||
// declared before the alias naming it. An anchor aliased from inside itself is the exception, and
|
||||
// recurses here until it exhausts budget.
|
||||
func copyExpanded(node *yaml.Node, budget *int) (*yaml.Node, error) {
|
||||
if *budget--; *budget < 0 {
|
||||
return nil, errTooManyYamlNodes
|
||||
}
|
||||
if node.Kind == yaml.AliasNode {
|
||||
return copyExpanded(node.Alias, budget)
|
||||
}
|
||||
|
||||
copied := *node
|
||||
copied.Content = make([]*yaml.Node, len(node.Content))
|
||||
for i, child := range node.Content {
|
||||
child, err := copyExpanded(child, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copied.Content[i] = child
|
||||
}
|
||||
return &copied, nil
|
||||
}
|
||||
|
||||
// rejectMergeKeys refuses `<<: *anchor`, same as GitHub does
|
||||
func rejectMergeKeys(node *yaml.Node) error {
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < len(node.Content)-1; i += 2 {
|
||||
if node.Content[i].Tag == "!!merge" {
|
||||
return errors.New("merge keys (`<<`) are not supported, alias the whole value instead")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseResolvesAliases(t *testing.T) {
|
||||
got, err := Parse([]byte(`on: push
|
||||
env: &common_env
|
||||
SHARED: "1"
|
||||
jobs:
|
||||
a:
|
||||
runs-on: linux
|
||||
env: *common_env
|
||||
steps: &common_steps [{run: echo hi}]
|
||||
b:
|
||||
runs-on: linux
|
||||
env: *common_env
|
||||
steps: *common_steps
|
||||
`))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
|
||||
for _, workflow := range got {
|
||||
_, job := workflow.Job()
|
||||
var env map[string]string
|
||||
require.NoError(t, job.Env.Decode(&env))
|
||||
assert.Equal(t, map[string]string{"SHARED": "1"}, env)
|
||||
require.Len(t, job.Steps, 1)
|
||||
|
||||
payload, err := workflow.Marshal()
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(payload), "common_")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsAliases(t *testing.T) {
|
||||
job := func(body string) []byte {
|
||||
return []byte("on: push\njobs:\n a:\n runs-on: linux\n" + body)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name, wantErr string
|
||||
content []byte
|
||||
}{
|
||||
{
|
||||
name: "nested aliases exceed the node limit",
|
||||
content: []byte(`on: push
|
||||
x0: &x0 [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
x1: &x1 [*x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0, *x0]
|
||||
x2: &x2 [*x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1, *x1]
|
||||
x3: &x3 [*x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2, *x2]
|
||||
x4: &x4 [*x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3, *x3]
|
||||
jobs: {a: {runs-on: linux, steps: [{run: echo}]}}
|
||||
`),
|
||||
wantErr: "maximum YAML nodes exceeded",
|
||||
},
|
||||
{
|
||||
name: "anchor aliased from inside itself",
|
||||
content: job(" steps: &s [{run: echo}, *s]\n"),
|
||||
wantErr: "maximum YAML nodes exceeded",
|
||||
},
|
||||
{
|
||||
name: "merge key",
|
||||
content: job(" env: &e {X: \"1\"}\n container:\n image: alpine\n env:\n <<: *e\n"),
|
||||
wantErr: "merge keys (`<<`) are not supported",
|
||||
},
|
||||
{
|
||||
name: "alias before its anchor",
|
||||
content: job(" env: *e\n container: {image: alpine, env: &e {X: \"1\"}}\n"),
|
||||
wantErr: "unknown anchor 'e' referenced",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Parse(tt.content)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ package jobparser
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
@@ -23,12 +26,6 @@ func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvalu
|
||||
return &ExpressionEvaluator{interpreter: interpreter}
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) {
|
||||
evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
|
||||
|
||||
return evaluated, err
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
||||
var in string
|
||||
if err := node.Decode(&in); err != nil {
|
||||
@@ -37,17 +34,17 @@ func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return nil
|
||||
}
|
||||
expr, _ := rewriteSubExpression(in, false)
|
||||
res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
res, err := ee.evaluateScalar(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return node.Encode(res)
|
||||
}
|
||||
|
||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||
var insertDirective = regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||
|
||||
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
|
||||
// GitHub has this undocumented feature to merge maps, called insert directive
|
||||
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
|
||||
for i := 0; i < len(node.Content)/2; {
|
||||
k := node.Content[i*2]
|
||||
v := node.Content[i*2+1]
|
||||
@@ -102,88 +99,170 @@ func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
|
||||
}
|
||||
}
|
||||
|
||||
func (ee ExpressionEvaluator) Interpolate(in string) string {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return in
|
||||
}
|
||||
|
||||
expr, _ := rewriteSubExpression(in, true)
|
||||
evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
||||
func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
value, ok := evaluated.(string)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr))
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func escapeFormatString(in string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}")
|
||||
}
|
||||
|
||||
func rewriteSubExpression(in string, forceFormat bool) (string, error) {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
if len(parts) == 1 && !parts[0].isExpr {
|
||||
return in, nil
|
||||
}
|
||||
var out strings.Builder
|
||||
out.Grow(len(in))
|
||||
for _, part := range parts {
|
||||
if !part.isExpr {
|
||||
out.WriteString(part.text)
|
||||
continue
|
||||
}
|
||||
evaluated, err := ee.interpreter.Evaluate(part.text, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out.WriteString(coerceToString(evaluated))
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
strPattern := regexp.MustCompile("(?:''|[^'])*'")
|
||||
pos := 0
|
||||
exprStart := -1
|
||||
strStart := -1
|
||||
var results []string
|
||||
var formatOut strings.Builder
|
||||
for pos < len(in) {
|
||||
if strStart > -1 {
|
||||
matches := strPattern.FindStringIndex(in[pos:])
|
||||
if matches == nil {
|
||||
return "", errors.New("unclosed string")
|
||||
}
|
||||
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
||||
func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(parts) == 1 && parts[0].isExpr {
|
||||
return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
|
||||
}
|
||||
return ee.interpolate(in)
|
||||
}
|
||||
|
||||
strStart = -1
|
||||
pos += matches[1]
|
||||
} else if exprStart > -1 {
|
||||
exprEnd := strings.Index(in[pos:], "}}")
|
||||
strStart = strings.Index(in[pos:], "'")
|
||||
// evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
|
||||
// interpolates to a string, so the success() default applies to it separately.
|
||||
func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
|
||||
parts, err := splitSubExpressions(in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
evaluated, err := ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckSuccess)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(evaluated), nil
|
||||
}
|
||||
|
||||
if exprEnd > -1 && strStart > -1 {
|
||||
if exprEnd < strStart {
|
||||
strStart = -1
|
||||
} else {
|
||||
exprEnd = -1
|
||||
}
|
||||
}
|
||||
|
||||
if exprEnd > -1 {
|
||||
fmt.Fprintf(&formatOut, "{%d}", len(results))
|
||||
results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd]))
|
||||
pos += exprEnd + 2
|
||||
exprStart = -1
|
||||
} else if strStart > -1 {
|
||||
pos += strStart + 1
|
||||
} else {
|
||||
panic("unclosed expression.")
|
||||
}
|
||||
} else {
|
||||
exprStart = strings.Index(in[pos:], "${{")
|
||||
if exprStart != -1 {
|
||||
formatOut.WriteString(escapeFormatString(in[pos : pos+exprStart]))
|
||||
exprStart = pos + exprStart + 3
|
||||
pos = exprStart
|
||||
} else {
|
||||
formatOut.WriteString(escapeFormatString(in[pos:]))
|
||||
pos = len(in)
|
||||
}
|
||||
// mixed content is a string, so the success() default applies to it separately
|
||||
if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
|
||||
status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exprparser.IsTruthy(status) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
interpolated, err := ee.interpolate(in)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(interpolated), nil
|
||||
}
|
||||
|
||||
if len(results) == 1 && formatOut.String() == "{0}" && !forceFormat {
|
||||
return in, nil
|
||||
// coerceToString converts an evaluated expression value to a string the way GitHub does,
|
||||
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
|
||||
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
|
||||
func coerceToString(v any) string {
|
||||
value, ok := v.(reflect.Value)
|
||||
if !ok {
|
||||
value = reflect.ValueOf(v)
|
||||
}
|
||||
|
||||
out := fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut.String(), "'", "''"), strings.Join(results, ", "))
|
||||
return out, nil
|
||||
switch value.Kind() {
|
||||
case reflect.Invalid:
|
||||
return ""
|
||||
|
||||
case reflect.Bool:
|
||||
return strconv.FormatBool(value.Bool())
|
||||
|
||||
case reflect.String:
|
||||
return value.String()
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return strconv.FormatInt(value.Int(), 10)
|
||||
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return strconv.FormatUint(value.Uint(), 10)
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
if math.IsInf(value.Float(), 1) {
|
||||
return "Infinity"
|
||||
} else if math.IsInf(value.Float(), -1) {
|
||||
return "-Infinity"
|
||||
}
|
||||
return fmt.Sprintf("%.15G", value.Float())
|
||||
|
||||
case reflect.Slice, reflect.Array:
|
||||
return "Array"
|
||||
|
||||
// contexts such as `github` are pointers to structs, so they stringify as objects too
|
||||
case reflect.Map, reflect.Struct:
|
||||
return "Object"
|
||||
|
||||
case reflect.Interface, reflect.Pointer:
|
||||
if value.IsNil() {
|
||||
return ""
|
||||
}
|
||||
return coerceToString(value.Elem())
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
|
||||
type exprPart struct {
|
||||
text string
|
||||
isExpr bool
|
||||
}
|
||||
|
||||
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
|
||||
// complete expression literal.
|
||||
func splitSubExpressions(in string) ([]exprPart, error) {
|
||||
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
||||
return []exprPart{{text: in}}, nil
|
||||
}
|
||||
|
||||
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
|
||||
for {
|
||||
start := strings.Index(in, "${{")
|
||||
if start < 0 {
|
||||
if in != "" {
|
||||
parts = append(parts, exprPart{text: in})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
if start > 0 {
|
||||
parts = append(parts, exprPart{text: in[:start]})
|
||||
}
|
||||
rest := in[start+len("${{"):]
|
||||
end := indexExprEnd(rest)
|
||||
if end < 0 {
|
||||
return nil, errors.New("unclosed expression")
|
||||
}
|
||||
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
|
||||
in = rest[end+len("}}"):]
|
||||
}
|
||||
}
|
||||
|
||||
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
|
||||
// state, so a `}}` inside a string does not end it.
|
||||
func indexExprEnd(in string) int {
|
||||
inString := false
|
||||
for i := range len(in) {
|
||||
switch {
|
||||
case in[i] == '\'':
|
||||
inString = !inString
|
||||
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -4,25 +4,33 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/rhysd/actionlint"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
origin, err := model.ReadWorkflow(bytes.NewReader(content))
|
||||
// The workflow is split into one document per job below, which would strand an alias whose
|
||||
// anchor lands in another one.
|
||||
doc, err := resolveYamlAliases(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model.ReadWorkflow: %w", err)
|
||||
return nil, fmt.Errorf("resolve aliases: %w", err)
|
||||
}
|
||||
|
||||
origin, err := readWorkflowDoc(doc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read workflow: %w", err)
|
||||
}
|
||||
|
||||
workflow := &SingleWorkflow{}
|
||||
if err := yaml.Unmarshal(content, workflow); err != nil {
|
||||
return nil, fmt.Errorf("yaml.Unmarshal: %w", err)
|
||||
if err := decodeYamlDoc(doc, workflow); err != nil {
|
||||
return nil, fmt.Errorf("decode workflow: %w", err)
|
||||
}
|
||||
|
||||
pc := &parseContext{}
|
||||
@@ -48,7 +56,9 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
}
|
||||
|
||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(&exprparser.EvaluationEnvironment{Github: pc.gitContext, Vars: pc.vars, Inputs: pc.inputs}, exprparser.Config{}))
|
||||
workflow.RunName = evaluator.Interpolate(workflow.RunName)
|
||||
if workflow.RunName, err = evaluator.interpolate(workflow.RunName); err != nil {
|
||||
return nil, fmt.Errorf("interpolate run-name: %w", err)
|
||||
}
|
||||
|
||||
for i, id := range ids {
|
||||
job := jobs[i]
|
||||
@@ -63,10 +73,14 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) {
|
||||
}
|
||||
job.Strategy.RawMatrix = encodeMatrix(matrix)
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(id, origin.GetJob(id), matrix, pc.gitContext, results, pc.vars, pc.inputs))
|
||||
job.Name = nameWithMatrix(job.Name, matrix, evaluator)
|
||||
if job.Name, err = nameWithMatrix(job.Name, matrix, evaluator); err != nil {
|
||||
return nil, fmt.Errorf("interpolate name for job %q: %w", id, err)
|
||||
}
|
||||
runsOn := origin.GetJob(id).RunsOn()
|
||||
for i, v := range runsOn {
|
||||
runsOn[i] = evaluator.Interpolate(v)
|
||||
if runsOn[i], err = evaluator.interpolate(v); err != nil {
|
||||
return nil, fmt.Errorf("interpolate runs-on for job %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
job.RawRunsOn = encodeRunsOn(runsOn)
|
||||
if err := evaluator.EvaluateYamlNode(&job.RawContinueOnError); err != nil {
|
||||
@@ -117,6 +131,9 @@ type parseContext struct {
|
||||
type ParseOption func(c *parseContext)
|
||||
|
||||
func getMatrixes(job *model.Job) ([]map[string]any, error) {
|
||||
if err := validateMatrixFilters(job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret, err := job.GetMatrixes()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetMatrixes: %w", err)
|
||||
@@ -127,6 +144,32 @@ func getMatrixes(job *model.Job) ([]map[string]any, error) {
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// validateMatrixFilters rejects an `include`/`exclude` that is not a list of mappings, so that the
|
||||
// usual way to get there, an unevaluated ${{ }} expression that is still a scalar, is named as such
|
||||
// instead of panicking inside the expansion.
|
||||
func validateMatrixFilters(job *model.Job) error {
|
||||
if job.Strategy == nil || job.Strategy.RawMatrix.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
content := job.Strategy.RawMatrix.Content
|
||||
for i := 0; i+1 < len(content); i += 2 {
|
||||
name, value := content[i].Value, content[i+1]
|
||||
if name != "include" && name != "exclude" {
|
||||
continue
|
||||
}
|
||||
entries := []*yaml.Node{value}
|
||||
if value.Kind == yaml.SequenceNode {
|
||||
entries = value.Content
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.Kind != yaml.MappingNode {
|
||||
return fmt.Errorf("matrix %s must be a list of mappings", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeMatrix(matrix map[string]any) yaml.Node {
|
||||
if len(matrix) == 0 {
|
||||
return yaml.Node{}
|
||||
@@ -150,16 +193,45 @@ func encodeRunsOn(runsOn []string) yaml.Node {
|
||||
return node
|
||||
}
|
||||
|
||||
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) string {
|
||||
func nameWithMatrix(name string, m map[string]any, evaluator *ExpressionEvaluator) (string, error) {
|
||||
if len(m) == 0 {
|
||||
return name
|
||||
return name, nil
|
||||
}
|
||||
|
||||
if !strings.Contains(name, "${{") || !strings.Contains(name, "}}") {
|
||||
return name + " " + matrixName(m)
|
||||
return name + " " + matrixName(m), nil
|
||||
}
|
||||
|
||||
return evaluator.Interpolate(name)
|
||||
return evaluator.interpolate(name)
|
||||
}
|
||||
|
||||
// expressionCallsFunction reports whether any ${{ }} expression in value calls one of the functions.
|
||||
func expressionCallsFunction(value string, names ...string) bool {
|
||||
parts, err := splitSubExpressions(value)
|
||||
if err != nil {
|
||||
return true // unparseable here, let the expansion report it against the real values
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !part.isExpr {
|
||||
continue
|
||||
}
|
||||
// The lexer needs the closing `}}` that the scanner strips.
|
||||
expr, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}"))
|
||||
if err != nil {
|
||||
return true // unparseable here, let the expansion report it against the real values
|
||||
}
|
||||
found := false
|
||||
actionlint.VisitExprNode(expr, func(node, _ actionlint.ExprNode, entering bool) {
|
||||
call, ok := node.(*actionlint.FuncCallNode)
|
||||
if entering && ok && slices.Contains(names, strings.ToLower(call.Callee)) {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
if found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matrixName(m map[string]any) string {
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
package jobparser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.yaml.in/yaml/v4"
|
||||
@@ -107,3 +109,86 @@ func TestParse(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInterpolatesRunName(t *testing.T) {
|
||||
workflow := func(runName string) []byte {
|
||||
return []byte("name: t\nrun-name: \"" + runName + "\"\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps: [{run: echo}]\n")
|
||||
}
|
||||
|
||||
for _, tt := range []struct{ name, runName, want string }{
|
||||
{"bool", "${{ true }}", "true"},
|
||||
{"int", "${{ 1 }}", "1"},
|
||||
{"float", "${{ 1.0 }}", "1"},
|
||||
{"null", "${{ null }}", ""},
|
||||
{"object", `${{ fromJSON('{\"a\":1}') }}`, "Object"},
|
||||
{"array", "${{ fromJSON('[1,2]') }}", "Array"},
|
||||
{"context", "${{ github }}", "Object"},
|
||||
{"surrounding literals", "run ${{ 1 }} now", "run 1 now"},
|
||||
{"two expressions", "${{ 1 }}-${{ true }}", "1-true"},
|
||||
{"closing brace inside a string", "${{ 'a}}b' }}", "a}}b"},
|
||||
{"incomplete expression stays literal", "${{ 1", "${{ 1"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := Parse(workflow(tt.runName), WithGitContext(&model.GithubContext{EventName: "push"}))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, tt.want, result[0].RunName)
|
||||
})
|
||||
}
|
||||
|
||||
// a malformed part must not restructure the surrounding expression
|
||||
for _, runName := range []string{"${{ 1) && (2 }}", "run ${{ 1) && (2 }} now", "${{ 'a' }} ${{ b", "${{ 'a }}"} {
|
||||
_, err := Parse(workflow(runName), WithGitContext(&model.GithubContext{EventName: "push"}))
|
||||
assert.ErrorContains(t, err, "interpolate run-name")
|
||||
}
|
||||
|
||||
// callers such as commit status parse without a git context, leaving `github` a nil pointer
|
||||
result, err := Parse(workflow("${{ github }}"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 1)
|
||||
assert.Empty(t, result[0].RunName)
|
||||
}
|
||||
|
||||
func TestRejectsUnevaluatedMatrixFilters(t *testing.T) {
|
||||
// An unevaluated ${{ }} expression is still a scalar, which act cannot apply as a filter: it used
|
||||
// to reach the expansion and panic there on an unchecked type assertion, taking down the file view
|
||||
// and the push_update queue.
|
||||
const workflow = `
|
||||
name: t
|
||||
on: push
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
m: ${{ steps.s.outputs.m }}
|
||||
steps: [{id: s, run: echo}]
|
||||
build:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
%s
|
||||
steps: [{run: echo}]
|
||||
`
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
matrix string
|
||||
}{
|
||||
{name: "include expression", matrix: "include: ${{ fromJson(needs.setup.outputs.m) }}"},
|
||||
{name: "exclude expression", matrix: "os: [a, b]\n exclude: ${{ fromJson(vars.MATRIX) }}"},
|
||||
{name: "include scalar", matrix: "include: whatever"},
|
||||
{name: "include list of scalars", matrix: "include: [a, b]"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.NotPanics(t, func() {
|
||||
_, err := Parse(fmt.Appendf(nil, workflow, tt.matrix))
|
||||
require.ErrorContains(t, err, "must be a list of mappings")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// a well-formed include/exclude keeps expanding
|
||||
planned, err := Parse(fmt.Appendf(nil, workflow, "os: [a, b]\n include:\n - os: c\n exclude:\n - os: b"))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, planned, 3) // setup, plus build for os a and c
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.com/gitea/runner/act/exprparser"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
@@ -32,6 +33,11 @@ func (w *SingleWorkflow) Job() (string, *Job) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// WorkflowDispatchConfig returns the `on: workflow_dispatch` declaration, nil if there is none.
|
||||
func (w *SingleWorkflow) WorkflowDispatchConfig() *model.WorkflowDispatch {
|
||||
return (&model.Workflow{RawOn: w.RawOn}).WorkflowDispatchConfig()
|
||||
}
|
||||
|
||||
func (w *SingleWorkflow) jobs() ([]string, []*Job, error) {
|
||||
ids, jobs, err := parseMappingNode[*Job](&w.RawJobs)
|
||||
if err != nil {
|
||||
@@ -251,9 +257,11 @@ func (evt *Event) Inputs() []WorkflowDispatchInput {
|
||||
}
|
||||
|
||||
func ReadWorkflowRawConcurrency(content []byte) (*model.RawConcurrency, error) {
|
||||
w := new(model.Workflow)
|
||||
err := yaml.NewDecoder(bytes.NewReader(content)).Decode(w)
|
||||
return w.RawConcurrency, err
|
||||
w, err := ReadWorkflow(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w.RawConcurrency, nil
|
||||
}
|
||||
|
||||
func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCtx map[string]any, results map[string]*JobResult, vars map[string]string, inputs map[string]any) (string, bool, error) {
|
||||
@@ -292,7 +300,7 @@ func EvaluateConcurrency(rc *model.RawConcurrency, jobID string, job *Job, gitCt
|
||||
if evaluated.RawExpression != "" {
|
||||
return evaluated.RawExpression, false, nil
|
||||
}
|
||||
return evaluated.Group, evaluated.CancelInProgress == "true", nil
|
||||
return evaluated.Group, util.ParseYamlBool(evaluated.CancelInProgress), nil
|
||||
}
|
||||
|
||||
func toGitContext(input map[string]any) *model.GithubContext {
|
||||
@@ -518,15 +526,7 @@ func EvaluateJobIfExpression(jobID string, job *Job, gitCtx map[string]any, resu
|
||||
matrix = matrixes[0]
|
||||
}
|
||||
evaluator := NewExpressionEvaluator(NewInterpeter(jobID, actJob, matrix, toGitContext(gitCtx), results, vars, inputs))
|
||||
expr, err := rewriteSubExpression(job.If.Value, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
result, err := evaluator.evaluate(expr, exprparser.DefaultStatusCheckSuccess)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exprparser.IsTruthy(result), nil
|
||||
return evaluator.evaluateCondition(job.If.Value)
|
||||
}
|
||||
|
||||
// parseMappingNode parse a mapping node and preserve order.
|
||||
|
||||
@@ -527,6 +527,10 @@ func TestEvaluateJobIfExpression(t *testing.T) {
|
||||
{name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false},
|
||||
{name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true},
|
||||
{name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false},
|
||||
// a condition is an expression with or without `${{ }}`, literal text around one makes it a string
|
||||
{name: "bare expression", ifCond: "always()", needResult: "failure", expected: true},
|
||||
{name: "literal text keeps the success() default", ifCond: "x ${{ 1 }}", needResult: "failure", expected: false},
|
||||
{name: "literal text around a status function drops it", ifCond: "x ${{ always() }}", needResult: "failure", expected: true},
|
||||
}
|
||||
for _, kase := range kases {
|
||||
t.Run(kase.name, func(t *testing.T) {
|
||||
|
||||
@@ -62,7 +62,7 @@ func ParseWorkflowCallSpec(content []byte) (*WorkflowCallSpec, error) {
|
||||
var doc struct {
|
||||
On yaml.Node `yaml:"on"`
|
||||
}
|
||||
if err := yaml.Unmarshal(content, &doc); err != nil {
|
||||
if err := decodeResolved(content, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse workflow yaml: %w", err)
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ func MatchCallerInputsAgainstSpec(spec *WorkflowCallSpec, evaluated map[string]a
|
||||
func parseWorkflowCallInput(name string, typ InputType, v any) (any, error) {
|
||||
switch typ {
|
||||
case InputTypeString:
|
||||
return toString(v), nil
|
||||
return coerceToString(v), nil
|
||||
case InputTypeBoolean:
|
||||
// strict type matching: a boolean input only accepts a native bool, not a "true"/"false" string
|
||||
if b, ok := v.(bool); ok {
|
||||
@@ -361,11 +361,11 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
|
||||
Vars: vars,
|
||||
Inputs: inputs,
|
||||
}
|
||||
interpreter := exprparser.NewInterpeter(env, exprparser.Config{})
|
||||
evaluator := NewExpressionEvaluator(exprparser.NewInterpeter(env, exprparser.Config{}))
|
||||
|
||||
out := make(map[string]string, len(spec.Outputs))
|
||||
for name, o := range spec.Outputs {
|
||||
v, err := evaluateWorkflowCallOutputValue(interpreter, o.Value)
|
||||
v, err := evaluator.interpolate(o.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workflow_call output %q: %w", name, err)
|
||||
}
|
||||
@@ -373,29 +373,3 @@ func EvaluateWorkflowCallOutputs(spec *WorkflowCallSpec, gitCtx *model.GithubCon
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func evaluateWorkflowCallOutputValue(interpreter exprparser.Interpreter, value string) (string, error) {
|
||||
if !strings.Contains(value, "${{") || !strings.Contains(value, "}}") {
|
||||
return value, nil
|
||||
}
|
||||
expr, err := rewriteSubExpression(value, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
evaluated, err := interpreter.Evaluate(expr, exprparser.DefaultStatusCheckNone)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return toString(evaluated), nil
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ func ParseScopedWorkflows(sourceCommit *git.Commit) ([]*ParsedScopedWorkflow, er
|
||||
// It returns the workflows whose `on:` matches, and those that matched the event but were excluded by a branch/paths filter (filtered).
|
||||
func MatchScopedWorkflows(
|
||||
parsed []*ParsedScopedWorkflow,
|
||||
sourceCommitSHA string,
|
||||
consumerGitRepo *git.Repository,
|
||||
consumerCommit *git.Commit,
|
||||
triggedEvent webhook_module.HookEventType,
|
||||
@@ -71,9 +72,10 @@ func MatchScopedWorkflows(
|
||||
continue
|
||||
}
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
EntryName: p.EntryName,
|
||||
TriggerEvent: evt,
|
||||
Content: p.Content,
|
||||
SourceCommitSHA: sourceCommitSHA,
|
||||
}
|
||||
switch detectWorkflowMatch(consumerGitRepo, consumerCommit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
|
||||
@@ -30,8 +30,9 @@ func FullSteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep {
|
||||
// For example,
|
||||
// 1. preStep(Success) -> step1(Success) -> step2(Running) -> step3(Waiting) -> postStep(Waiting): lastHasRunStep is step1.
|
||||
// 2. preStep(Success) -> step1(Success) -> step2(Success) -> step3(Success) -> postStep(Success): lastHasRunStep is step3.
|
||||
// 3. preStep(Success) -> step1(Success) -> step2(Failure) -> step3 -> postStep(Waiting): lastHasRunStep is step2.
|
||||
// So its Stopped is the Started of postStep when there are no more steps to run.
|
||||
// 3. preStep(Success) -> step1(Success) -> step2(Failure) -> step3(Waiting) -> postStep(Waiting): lastHasRunStep is step2.
|
||||
// 4. preStep(Success) -> step1(Success) -> step2(Skipped) -> postStep(Success): lastHasRunStep is step1, because a skipped step never ran.
|
||||
// So its Stopped is the Started of postStep once no step is left to run.
|
||||
var lastHasRunStep *actions_model.ActionTaskStep
|
||||
|
||||
var logIndex int64
|
||||
@@ -71,8 +72,8 @@ func FullSteps(task *actions_model.ActionTask) []*actions_model.ActionTaskStep {
|
||||
Name: postStepName,
|
||||
Status: actions_model.StatusWaiting,
|
||||
}
|
||||
// If the lastHasRunStep is the last step, or it has failed, postStep has started.
|
||||
if lastHasRunStep.Status.IsFailure() || lastHasRunStep == task.Steps[len(task.Steps)-1] {
|
||||
// If no step is left to run, or the lastHasRunStep has failed, postStep has started.
|
||||
if lastHasRunStep.Status.IsFailure() || task.Steps[len(task.Steps)-1].Status.IsDone() {
|
||||
postStep.LogIndex = logIndex
|
||||
postStep.LogLength = task.LogLength - postStep.LogIndex
|
||||
postStep.Started = lastHasRunStep.Stopped
|
||||
|
||||
@@ -156,6 +156,27 @@ func TestFullSteps(t *testing.T) {
|
||||
{Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "last step is skipped",
|
||||
task: &actions_model.ActionTask{
|
||||
Steps: []*actions_model.ActionTaskStep{
|
||||
{Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 20, Started: 10010, Stopped: 10020},
|
||||
{Status: actions_model.StatusSuccess, LogIndex: 30, LogLength: 60, Started: 10020, Stopped: 10090},
|
||||
{Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
|
||||
},
|
||||
Status: actions_model.StatusSuccess,
|
||||
Started: 10000,
|
||||
Stopped: 10100,
|
||||
LogLength: 100,
|
||||
},
|
||||
want: []*actions_model.ActionTaskStep{
|
||||
{Name: preStepName, Status: actions_model.StatusSuccess, LogIndex: 0, LogLength: 10, Started: 10000, Stopped: 10010},
|
||||
{Status: actions_model.StatusSuccess, LogIndex: 10, LogLength: 20, Started: 10010, Stopped: 10020},
|
||||
{Status: actions_model.StatusSuccess, LogIndex: 30, LogLength: 60, Started: 10020, Stopped: 10090},
|
||||
{Status: actions_model.StatusSkipped, LogIndex: 0, LogLength: 0, Started: 0, Stopped: 0},
|
||||
{Name: postStepName, Status: actions_model.StatusSuccess, LogIndex: 90, LogLength: 10, Started: 10090, Stopped: 10100},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
@@ -28,6 +27,8 @@ type DetectedWorkflow struct {
|
||||
EntryName string
|
||||
TriggerEvent *jobparser.Event
|
||||
Content []byte
|
||||
// SourceCommitSHA is the commit Content was read from, and must always be filled in together with Content.
|
||||
SourceCommitSHA string
|
||||
}
|
||||
|
||||
type detectResult int
|
||||
@@ -118,7 +119,7 @@ func GetContentFromEntry(entry *git.TreeEntry) ([]byte, error) {
|
||||
}
|
||||
|
||||
func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) {
|
||||
workflow, err := model.ReadWorkflow(bytes.NewReader(content))
|
||||
workflow, err := jobparser.ReadWorkflow(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -203,17 +204,19 @@ func DetectWorkflows(
|
||||
if evt.IsSchedule() {
|
||||
if detectSchedule {
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
schedules = append(schedules, dwf)
|
||||
}
|
||||
} else {
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
switch detectWorkflowMatch(gitRepo, commit, triggedEvent, payload, evt) {
|
||||
case detectMatched:
|
||||
@@ -252,9 +255,10 @@ func DetectScheduledWorkflows(gitRepo *git.Repository, commit *git.Commit) ([]*D
|
||||
if evt.IsSchedule() {
|
||||
log.Trace("detect scheduled workflow: %q", entry.Name())
|
||||
dwf := &DetectedWorkflow{
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
EntryName: entry.Name(),
|
||||
TriggerEvent: evt,
|
||||
Content: content,
|
||||
SourceCommitSHA: commit.ID.String(),
|
||||
}
|
||||
wfs = append(wfs, dwf)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,10 @@ func Init() {
|
||||
|
||||
WebAuthn = &webauthn.WebAuthn{
|
||||
Config: &webauthn.Config{
|
||||
RPDisplayName: setting.AppName,
|
||||
RPID: setting.Domain,
|
||||
RPOrigins: []string{appURL},
|
||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||
UserVerification: protocol.VerificationDiscouraged,
|
||||
},
|
||||
AttestationPreference: protocol.PreferDirectAttestation,
|
||||
RPDisplayName: setting.AppName,
|
||||
RPID: setting.Domain,
|
||||
RPOrigins: []string{appURL},
|
||||
AttestationPreference: protocol.PreferNoAttestation, // Gitea never verifies attestation
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,9 +39,7 @@ type CommitMessage struct {
|
||||
|
||||
trailerValues CommitMessageTrailerValues
|
||||
|
||||
allParticipants []*CommitIdentity
|
||||
committerCoAuthorIdx int
|
||||
committerCoAuthor *CommitIdentity
|
||||
allAuthors []*CommitIdentity
|
||||
}
|
||||
|
||||
func (c *CommitMessage) MessageUTF8() string {
|
||||
@@ -146,63 +144,58 @@ func CommitMessageParseTrailer(s string) CommitMessageTrailerValues {
|
||||
return ret
|
||||
}
|
||||
|
||||
// AllParticipantIdentities returns all the participants in the commit, the first one is the commit's author
|
||||
func (c *Commit) AllParticipantIdentities() []*CommitIdentity {
|
||||
if c.allParticipants != nil {
|
||||
return c.allParticipants
|
||||
}
|
||||
|
||||
exclude := map[string]int{}
|
||||
addParticipant := func(name, email string, role int) (existingRole int) {
|
||||
if name == "" && email == "" {
|
||||
return 0
|
||||
}
|
||||
emailLower := strings.ToLower(email)
|
||||
if existingRole = exclude[emailLower]; emailLower != "" && existingRole != 0 {
|
||||
return existingRole
|
||||
}
|
||||
c.allParticipants = append(c.allParticipants, &CommitIdentity{Name: name, Email: email, role: role})
|
||||
exclude[emailLower] = role
|
||||
return 0
|
||||
}
|
||||
|
||||
c.committerCoAuthorIdx = -1
|
||||
addParticipant(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
|
||||
addParticipant(c.Committer.Name, c.Committer.Email, commitIdentityRoleCommitter)
|
||||
for _, coAuthorValue := range c.MessageTrailer()["co-authored-by"] {
|
||||
addr, err := mail.ParseAddress(coAuthorValue)
|
||||
coAuthorName, coAuthorEmail := coAuthorValue, ""
|
||||
if err == nil {
|
||||
coAuthorName, coAuthorEmail = addr.Name, addr.Address
|
||||
}
|
||||
existingRole := addParticipant(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
|
||||
if existingRole == commitIdentityRoleCommitter && c.committerCoAuthorIdx == -1 {
|
||||
c.committerCoAuthorIdx = len(c.allParticipants)
|
||||
c.committerCoAuthor = &CommitIdentity{coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor}
|
||||
}
|
||||
}
|
||||
return c.allParticipants
|
||||
}
|
||||
|
||||
// CoAuthorIdentities returns co-author identities defined by "Co-authored-by:" in the git message trailer
|
||||
// Only the commit's author is excluded. If committer is declared as co-author, it will be included in the result.
|
||||
// AllAuthorIdentities returns all the author and co-authors in the commit. Committer is not included:
|
||||
// * Author & Co-author: they changed the code (attribution)
|
||||
// * Committer: they submitted the commit but didn't change the code (e.g.: maintainer signed a commit)
|
||||
// So, a committer can also be a co-author if they changed the code.
|
||||
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
|
||||
all := c.AllParticipantIdentities()
|
||||
if len(all) <= 1 {
|
||||
return nil // no co-author list
|
||||
func (c *Commit) AllAuthorIdentities() []*CommitIdentity {
|
||||
if c.allAuthors != nil {
|
||||
return c.allAuthors
|
||||
}
|
||||
if all[1].role != commitIdentityRoleCommitter {
|
||||
return all[1:] // no committer, so all after author are co-authors
|
||||
trailerCoAuthors := c.MessageTrailer()["co-authored-by"]
|
||||
c.allAuthors = make([]*CommitIdentity, 0, 1+len(trailerCoAuthors))
|
||||
exclude := map[string]int{}
|
||||
addAuthor := func(name, email string, role int) {
|
||||
if name == "" && email == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(email)
|
||||
if key == "" {
|
||||
key = strings.ToLower(name)
|
||||
}
|
||||
if existingRole := exclude[key]; key != "" && existingRole != 0 {
|
||||
return
|
||||
}
|
||||
c.allAuthors = append(c.allAuthors, &CommitIdentity{Name: name, Email: email, role: role})
|
||||
exclude[key] = role
|
||||
}
|
||||
if c.committerCoAuthorIdx == -1 {
|
||||
return all[2:] // the committer is not in the co-author list, so just return the co-author list
|
||||
|
||||
addAuthor(c.Author.Name, c.Author.Email, commitIdentityRoleAuthor)
|
||||
for _, coAuthorValue := range trailerCoAuthors {
|
||||
coAuthorName, coAuthorEmail := parseCommitIdentityValue(coAuthorValue)
|
||||
addAuthor(coAuthorName, coAuthorEmail, commitIdentityRoleCoAuthor)
|
||||
}
|
||||
// the committer is in the co-author list but de-duplicated, so include them as co-author again
|
||||
coAuthors = append(coAuthors, all[2:c.committerCoAuthorIdx]...)
|
||||
coAuthors = append(coAuthors, c.committerCoAuthor)
|
||||
coAuthors = append(coAuthors, all[c.committerCoAuthorIdx:]...)
|
||||
return coAuthors
|
||||
return c.allAuthors
|
||||
}
|
||||
|
||||
// Git identities are not RFC 5322 addresses: net/mail rejects names like "dependabot[bot]", so fall back to the angle-addr.
|
||||
func parseCommitIdentityValue(value string) (name, email string) {
|
||||
if addr, err := mail.ParseAddress(value); err == nil {
|
||||
return addr.Name, addr.Address
|
||||
}
|
||||
begin, end := strings.LastIndex(value, "<"), strings.LastIndex(value, ">")
|
||||
if begin == -1 || end < begin {
|
||||
return value, ""
|
||||
}
|
||||
return strings.TrimSpace(value[:begin]), strings.TrimSpace(value[begin+1 : end])
|
||||
}
|
||||
|
||||
func (c *Commit) CoAuthorIdentities() (coAuthors []*CommitIdentity) {
|
||||
all := c.AllAuthorIdentities()
|
||||
if len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
if all[0].role == commitIdentityRoleAuthor {
|
||||
return all[1:]
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
@@ -52,41 +52,44 @@ func TestCommitMessageTrailer(t *testing.T) {
|
||||
func TestCommitMessageParticipants(t *testing.T) {
|
||||
sig := func(n, e string) *Signature { return &Signature{Name: n, Email: e} }
|
||||
idt := func(n, e string, r int) *CommitIdentity { return &CommitIdentity{n, e, r} }
|
||||
roleAuthor, roleCommitter, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
|
||||
roleAuthor, _, roleCoAuthor := commitIdentityRoleAuthor, commitIdentityRoleCommitter, commitIdentityRoleCoAuthor
|
||||
type testCase struct {
|
||||
name string
|
||||
commit *Commit
|
||||
identities []*CommitIdentity
|
||||
}
|
||||
t.Run("AllParticipants", func(t *testing.T) {
|
||||
|
||||
t.Run("AllAuthors", func(t *testing.T) {
|
||||
cases := []testCase{
|
||||
{
|
||||
"DifferentUsers",
|
||||
"CommitterExcluded",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: x@m.com"},
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: Full Name <x@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("c", "c@m.com", roleCommitter), idt("", "x@m.com", roleCoAuthor)},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "x@m.com", roleCoAuthor)},
|
||||
},
|
||||
{
|
||||
"SameUser",
|
||||
"AuthorIsCoAuthor",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("a", "A@M.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: a@m.com"},
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "CO-Authored-BY: other-name <a@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor)},
|
||||
},
|
||||
{
|
||||
"NoCommitter",
|
||||
"EmptyAuthor", // synthesized commits (push feed) may have no author signature at all
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("", ""),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: Full Name <X@M.com>"},
|
||||
Author: sig("", ""), Committer: sig("", ""),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: c <c@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{idt("a", "a@m.com", roleAuthor), idt("Full Name", "X@M.com", roleCoAuthor)},
|
||||
// but if the commit message contains co-authors, the co-authors are still parsed for "all authors"
|
||||
// if it is a problem, the caller should fix the problem (provide correct "author")
|
||||
[]*CommitIdentity{idt("c", "c@m.com", roleCoAuthor)},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assert.Equal(t, c.identities, c.commit.AllParticipantIdentities(), "case: %s", c.name)
|
||||
assert.Equal(t, c.identities, c.commit.AllAuthorIdentities(), "case: %s", c.name)
|
||||
}
|
||||
})
|
||||
t.Run("CoAuthors", func(t *testing.T) {
|
||||
@@ -116,12 +119,23 @@ func TestCommitMessageParticipants(t *testing.T) {
|
||||
[]*CommitIdentity{},
|
||||
},
|
||||
{
|
||||
"CoAuthorCommitterNameWithIndex", // restore the committer co-author to the co-author list by the index with correct name
|
||||
"CoAuthorNameOnlyAndDuplicate",
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: x <x@m.com>\nCo-authored-by: c-other <c@m.com>\nCo-authored-by: y <y@m.com>"},
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: b\nCo-authored-by: b\nCo-authored-by: c"},
|
||||
},
|
||||
[]*CommitIdentity{idt("b", "", roleCoAuthor), idt("c", "", roleCoAuthor)},
|
||||
},
|
||||
{
|
||||
"CoAuthorNameNotAnEmailAddress", // names net/mail rejects, e.g. bots and names with a comma
|
||||
&Commit{
|
||||
Author: sig("a", "a@m.com"), Committer: sig("c", "c@m.com"),
|
||||
CommitMessage: CommitMessage{MessageRaw: "Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>\nCo-authored-by: Smith, John <j@m.com>"},
|
||||
},
|
||||
[]*CommitIdentity{
|
||||
idt("dependabot[bot]", "49699333+dependabot[bot]@users.noreply.github.com", roleCoAuthor),
|
||||
idt("Smith, John", "j@m.com", roleCoAuthor),
|
||||
},
|
||||
[]*CommitIdentity{idt("x", "x@m.com", roleCoAuthor), idt("c-other", "c@m.com", roleCoAuthor), idt("y", "y@m.com", roleCoAuthor)},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -118,15 +118,19 @@ func createDelegateHooks(hookDir string) (err error) {
|
||||
oldHookPath := filepath.Join(hookDir, hookName)
|
||||
newHookPath := filepath.Join(hookDir, hookName+".d", "gitea")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(hookDir, hookName+".d"), os.ModePerm); err != nil {
|
||||
return fmt.Errorf("create hooks dir '%s': %w", filepath.Join(hookDir, hookName+".d"), err)
|
||||
hookDDir := filepath.Join(hookDir, hookName+".d")
|
||||
if err := os.MkdirAll(hookDDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create hooks dir '%s': %w", hookDDir, err)
|
||||
}
|
||||
if err := os.Chmod(hookDDir, 0o755); err != nil {
|
||||
return fmt.Errorf("chmod hooks dir '%s': %w", hookDDir, err)
|
||||
}
|
||||
|
||||
// WARNING: This will override all old server-side hooks
|
||||
if err = util.Remove(oldHookPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("unable to pre-remove old hook file '%s' prior to rewriting: %w ", oldHookPath, err)
|
||||
}
|
||||
if err = os.WriteFile(oldHookPath, []byte(hookTpls[i]), 0o777); err != nil {
|
||||
if err = os.WriteFile(oldHookPath, []byte(hookTpls[i]), 0o755); err != nil {
|
||||
return fmt.Errorf("write old hook file '%s': %w", oldHookPath, err)
|
||||
}
|
||||
|
||||
@@ -137,7 +141,7 @@ func createDelegateHooks(hookDir string) (err error) {
|
||||
if err = util.Remove(newHookPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("unable to pre-remove new hook file '%s' prior to rewriting: %w", newHookPath, err)
|
||||
}
|
||||
if err = os.WriteFile(newHookPath, []byte(giteaHookTpls[i]), 0o777); err != nil {
|
||||
if err = os.WriteFile(newHookPath, []byte(giteaHookTpls[i]), 0o755); err != nil {
|
||||
return fmt.Errorf("write new hook file '%s': %w", newHookPath, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package gitrepo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateDelegateHooksPermissions(t *testing.T) {
|
||||
hookDir := t.TempDir()
|
||||
existingHookDir := filepath.Join(hookDir, "post-receive.d")
|
||||
require.NoError(t, os.MkdirAll(existingHookDir, 0o777))
|
||||
require.NoError(t, os.Chmod(existingHookDir, 0o777))
|
||||
|
||||
require.NoError(t, createDelegateHooks(hookDir))
|
||||
|
||||
hookNames, _, _ := getHookTemplates()
|
||||
for _, hookName := range hookNames {
|
||||
for _, path := range []string{
|
||||
filepath.Join(hookDir, hookName),
|
||||
filepath.Join(hookDir, hookName+".d"),
|
||||
filepath.Join(hookDir, hookName+".d", "gitea"),
|
||||
} {
|
||||
info, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o755), info.Mode().Perm(), path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
analyzer_keyword "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/letter"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
|
||||
"github.com/blevesearch/bleve/v2/mapping"
|
||||
"github.com/blevesearch/bleve/v2/search/query"
|
||||
@@ -69,9 +68,8 @@ func (d *RepoIndexerData) Type() string {
|
||||
const (
|
||||
repoIndexerAnalyzer = "repoIndexerAnalyzer"
|
||||
filenameIndexerAnalyzer = "filenameIndexerAnalyzer"
|
||||
filenameIndexerTokenizer = "filenameIndexerTokenizer"
|
||||
repoIndexerDocType = "repoIndexerDocType"
|
||||
repoIndexerLatestVersion = 9
|
||||
repoIndexerLatestVersion = 10
|
||||
)
|
||||
|
||||
// generateBleveIndexMapping generates a bleve index mapping for the repo indexer
|
||||
@@ -107,8 +105,8 @@ func generateBleveIndexMapping() (mapping.IndexMapping, error) {
|
||||
} else if err := mapping.AddCustomAnalyzer(repoIndexerAnalyzer, map[string]any{
|
||||
"type": analyzer_custom.Name,
|
||||
"char_filters": []string{},
|
||||
"tokenizer": letter.Name,
|
||||
"token_filters": []string{unicodeNormalizeName, lowercase.Name},
|
||||
"tokenizer": codeTokenizerName,
|
||||
"token_filters": []string{unicodeNormalizeName, codeTokenFilterName, lowercase.Name},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/modules/indexer/code/internal"
|
||||
inner_bleve "gitea.dev/modules/indexer/internal/bleve"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBleveIndexerTokenFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
indexer := NewIndexer(dir)
|
||||
defer indexer.Close()
|
||||
|
||||
_, err := indexer.Init(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
batch := inner_bleve.NewFlushingBatch(indexer.inner.Indexer, maxBatchSize)
|
||||
batch.Index("2", &RepoIndexerData{RepoID: 2, Content: "mDNS.port2=12345", UpdatedAt: time.Now()})
|
||||
batch.Flush()
|
||||
|
||||
testCases := []struct {
|
||||
keyword string
|
||||
expectedIDs []int64
|
||||
}{
|
||||
{keyword: "12345", expectedIDs: []int64{2}},
|
||||
{keyword: "DNS", expectedIDs: []int64{}},
|
||||
{keyword: "mdns", expectedIDs: []int64{2}},
|
||||
{keyword: "port", expectedIDs: []int64{2}},
|
||||
{keyword: "port2", expectedIDs: []int64{2}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.keyword, func(t *testing.T) {
|
||||
_, results, _, err := indexer.Search(t.Context(), &internal.SearchOptions{
|
||||
Paginator: &db.ListOptions{Page: 1, PageSize: 1},
|
||||
Keyword: testCase.keyword,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, testCase.expectedIDs, searchResultIDs(results))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func searchResultIDs(result []*internal.SearchResult) []int64 {
|
||||
ids := make([]int64, 0, len(result))
|
||||
for _, hit := range result {
|
||||
ids = append(ids, hit.RepoID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"unicode"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/blevesearch/bleve/v2/analysis"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/character"
|
||||
"github.com/blevesearch/bleve/v2/registry"
|
||||
)
|
||||
|
||||
const codeTokenizerName = "codeTokenizer"
|
||||
|
||||
func codeTokenizerConstructor(_ map[string]any, _ *registry.Cache) (analysis.Tokenizer, error) {
|
||||
// Old code used "letter" tokenizer which doesn't support CJK.
|
||||
// Here it still doesn't support CJK, since there is no usable CJK tokenizer at the moment.
|
||||
return character.NewCharacterTokenizer(func(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsNumber(r)
|
||||
}), nil
|
||||
}
|
||||
|
||||
const codeTokenFilterName = "codeTokenFilter"
|
||||
|
||||
type codeTokenFilter struct {
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
func (c codeTokenFilter) Filter(stream analysis.TokenStream) (ret analysis.TokenStream) {
|
||||
// split one token to "letter" parts and "number" parts (to keep the old behavior).
|
||||
// e.g.: input token="port123", then the output tokens are "port123", "port", "123"
|
||||
for _, token := range stream {
|
||||
ret = append(ret, token)
|
||||
m := c.re.FindAllIndex(token.Term, -1)
|
||||
if len(m) > 1 {
|
||||
for _, it := range m {
|
||||
p1, p2 := it[0], it[1]
|
||||
t := &analysis.Token{
|
||||
Start: token.Start + p1,
|
||||
End: token.Start + p2,
|
||||
Term: token.Term[p1:p2],
|
||||
Position: token.Position,
|
||||
Type: analysis.AlphaNumeric,
|
||||
}
|
||||
ret = append(ret, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func codeTokenFilterConstructor(_ map[string]any, _ *registry.Cache) (analysis.TokenFilter, error) {
|
||||
return &codeTokenFilter{
|
||||
re: regexp.MustCompile("[a-zA-Z]+|[0-9]+"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
util.MustNoError(registry.RegisterTokenizer(codeTokenizerName, codeTokenizerConstructor))
|
||||
util.MustNoError(registry.RegisterTokenFilter(codeTokenFilterName, codeTokenFilterConstructor))
|
||||
}
|
||||
@@ -7,13 +7,13 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/blevesearch/bleve/v2/analysis"
|
||||
"github.com/blevesearch/bleve/v2/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "gitea/path"
|
||||
)
|
||||
const Name = "gitea/path"
|
||||
|
||||
type TokenFilter struct{}
|
||||
|
||||
@@ -98,8 +98,5 @@ func generatePathTokens(input analysis.TokenStream, reversed bool) analysis.Toke
|
||||
|
||||
func init() {
|
||||
// FIXME: move it to the bleve's init function, but do not call it in global init
|
||||
err := registry.RegisterTokenFilter(Name, TokenFilterConstructor)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
util.MustNoError(registry.RegisterTokenFilter(Name, TokenFilterConstructor))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/blevesearch/bleve/v2/analysis/analyzer/custom"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/lowercase"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"
|
||||
"github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode"
|
||||
@@ -26,7 +25,7 @@ import (
|
||||
const (
|
||||
issueIndexerAnalyzer = "issueIndexer"
|
||||
issueIndexerDocType = "issueIndexerDocType"
|
||||
issueIndexerLatestVersion = 7
|
||||
issueIndexerLatestVersion = 8
|
||||
)
|
||||
|
||||
const unicodeNormalizeName = "unicodeNormalize"
|
||||
@@ -103,7 +102,7 @@ func generateIssueIndexMapping() (mapping.IndexMapping, error) {
|
||||
"type": custom.Name,
|
||||
"char_filters": []string{},
|
||||
"tokenizer": unicode.Name,
|
||||
"token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name},
|
||||
"token_filters": []string{unicodeNormalizeName, camelCaseKeepWholeName, lowercase.Name},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -77,6 +77,47 @@ func TestBleveIndexerNoAssignee(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBleveIndexerTokenFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
indexer := NewIndexer(dir)
|
||||
defer indexer.Close()
|
||||
|
||||
_, err := indexer.Init(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, indexer.Index(t.Context(),
|
||||
&internal.IndexerData{ID: 1, Title: "fix(packages): SomeThing needs a rewrite (#12345)"},
|
||||
&internal.IndexerData{ID: 2, Title: "add support for mDNS discovery abc1234"},
|
||||
))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
keyword string
|
||||
expectedIDs []int64
|
||||
}{
|
||||
{name: "exact original case", keyword: "SomeThing", expectedIDs: []int64{1}},
|
||||
{name: "case matching original transitions", keyword: "someThing", expectedIDs: []int64{1}},
|
||||
{name: "all lower case", keyword: "something", expectedIDs: []int64{1}},
|
||||
{name: "all upper case", keyword: "SOMETHING", expectedIDs: []int64{1}},
|
||||
{name: "number match", keyword: "12345", expectedIDs: []int64{1}},
|
||||
{name: "number as part", keyword: "1234", expectedIDs: []int64{2}},
|
||||
{name: "sub-word search still works", keyword: "DNS", expectedIDs: []int64{2}},
|
||||
{name: "sub-word search, lower case", keyword: "mdns", expectedIDs: []int64{2}},
|
||||
{name: "keyword is camel case", keyword: "addSupport", expectedIDs: []int64{2}},
|
||||
{name: "keyword not match", keyword: "addsupport", expectedIDs: []int64{}},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
result, err := indexer.Search(t.Context(), &internal.SearchOptions{
|
||||
Keyword: testCase.keyword,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, testCase.expectedIDs, searchResultIDs(result))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func searchResultIDs(result *internal.SearchResult) []int64 {
|
||||
ids := make([]int64, 0, len(result.Hits))
|
||||
for _, hit := range result.Hits {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package bleve
|
||||
|
||||
import (
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/blevesearch/bleve/v2/analysis"
|
||||
"github.com/blevesearch/bleve/v2/analysis/token/camelcase"
|
||||
"github.com/blevesearch/bleve/v2/registry"
|
||||
)
|
||||
|
||||
const camelCaseKeepWholeName = "camelCaseKeepWhole"
|
||||
|
||||
// camelCaseKeepWholeFilter behaves like bleve's built-in "camelCase" token filter,
|
||||
// it also uses the whole word for a token. For example: when indexing "someThing",
|
||||
// CamelCaseFilter only emits "some" and "thing", this filter also emits "something".
|
||||
// It is questionable why the "issue indexer" used the CamelCaseFilter, it just can't search "someThing".
|
||||
// To avoid breaking existing user experiences, this "whole token filter" is introduced to make the full word can be searched.
|
||||
type camelCaseKeepWholeFilter struct {
|
||||
inner *camelcase.CamelCaseFilter
|
||||
}
|
||||
|
||||
func (f *camelCaseKeepWholeFilter) Filter(input analysis.TokenStream) analysis.TokenStream {
|
||||
// First, do exactly what the stock camelCase filter does: split by "camelCase" tokens
|
||||
split := f.inner.Filter(input)
|
||||
|
||||
// Index the resulting position of the *first* sub-token produced for
|
||||
// each original token (matched by start offset), so the duplicated
|
||||
// whole-word token we add below lines up at the same position as the
|
||||
// sub-word it stands in for, instead of drifting out of sync for
|
||||
// fields with more than one original token.
|
||||
posByStart := make(map[int]int, len(split))
|
||||
for _, tok := range split {
|
||||
if _, ok := posByStart[tok.Start]; !ok {
|
||||
posByStart[tok.Start] = tok.Position
|
||||
}
|
||||
}
|
||||
|
||||
rv := make(analysis.TokenStream, 0, len(split)+len(input))
|
||||
rv = append(rv, split...)
|
||||
|
||||
// Then append one extra, un-split copy of every original token, so the
|
||||
// whole word survives as a standalone, independently searchable term.
|
||||
for _, token := range input {
|
||||
dup := *token
|
||||
dup.Term = append([]byte(nil), token.Term...)
|
||||
if pos, ok := posByStart[token.Start]; ok {
|
||||
dup.Position = pos
|
||||
}
|
||||
rv = append(rv, &dup)
|
||||
}
|
||||
|
||||
return rv
|
||||
}
|
||||
|
||||
func camelCaseKeepWholeFilterConstructor(_ map[string]any, _ *registry.Cache) (analysis.TokenFilter, error) {
|
||||
return &camelCaseKeepWholeFilter{inner: camelcase.NewCamelCaseFilter()}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
util.MustNoError(registry.RegisterTokenFilter(camelCaseKeepWholeName, camelCaseKeepWholeFilterConstructor))
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func createRequest(ctx context.Context, method, url string, headers map[string]s
|
||||
}
|
||||
|
||||
// performRequest sends a request, optionally performs a callback on the request and returns the response.
|
||||
// If the status code is 200, the response is returned, and it will contain a non-nil Body.
|
||||
// If the status code is in the 2xx range, the response is returned, and it will contain a non-nil Body.
|
||||
// Otherwise, it will return an error, and the Body will be nil or closed.
|
||||
func performRequest(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
log.Trace("performRequest: %s", req.URL)
|
||||
@@ -264,7 +264,7 @@ func performRequest(ctx context.Context, client *http.Client, req *http.Request)
|
||||
return res, err
|
||||
}
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
defer res.Body.Close()
|
||||
return res, handleErrorResponse(res)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,15 @@ func TestBasicTransferAdapter(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Upload created", func(t *testing.T) {
|
||||
client := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(""))}
|
||||
})}
|
||||
adapter := &BasicTransferAdapter{client: client}
|
||||
err := adapter.Upload(t.Context(), &Link{Href: "https://upload-created-request.io"}, p, strings.NewReader("dummy"))
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Verify", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
link *Link
|
||||
|
||||
Vendored
+33
-22
@@ -5,11 +5,11 @@ package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
@@ -91,52 +91,63 @@ func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOpti
|
||||
return ret
|
||||
}
|
||||
|
||||
func envMark(envName string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "%" + envName + "%"
|
||||
func (p *Renderer) prepareExternalCommand(vars map[string]string) (string, []string, error) {
|
||||
fields, err := shellquote.Split(strings.TrimSpace(p.Command))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "$" + envName
|
||||
if len(fields) == 0 {
|
||||
return "", nil, errors.New("no command")
|
||||
}
|
||||
var replacements []string
|
||||
for k, v := range vars {
|
||||
replacements = append(replacements, "$"+k, v)
|
||||
replacements = append(replacements, "%"+k+"%", v) // for legacy Windows-style support
|
||||
}
|
||||
r := strings.NewReplacer(replacements...)
|
||||
for i := range fields {
|
||||
fields[i] = r.Replace(fields[i])
|
||||
}
|
||||
return fields[0], fields[1:], nil
|
||||
}
|
||||
|
||||
// Render renders the data of the document to HTML via the external tool.
|
||||
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
|
||||
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
|
||||
command := strings.NewReplacer(
|
||||
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
|
||||
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
|
||||
).Replace(p.Command)
|
||||
commands, err := shellquote.Split(command)
|
||||
if err != nil || len(commands) == 0 {
|
||||
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
|
||||
cmdVars := map[string]string{
|
||||
"GITEA_PREFIX_SRC": baseLinkSrc,
|
||||
"GITEA_PREFIX_RAW": baseLinkRaw,
|
||||
}
|
||||
cmdProg, cmdArgs, err := p.prepareExternalCommand(cmdVars)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid external render (%s) command %q: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args := commands[1:]
|
||||
|
||||
if p.IsInputFile {
|
||||
// write to temp file
|
||||
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
tmpFile, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
_, err = io.Copy(f, input)
|
||||
_, err = io.Copy(tmpFile, input)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
err = tmpFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args = append(args, f.Name())
|
||||
cmdArgs = append(cmdArgs, tmpFile.Name())
|
||||
}
|
||||
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", commands[0], baseLinkSrc))
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", cmdProg, baseLinkSrc))
|
||||
defer finished()
|
||||
|
||||
cmd := exec.CommandContext(processCtx, commands[0], args...)
|
||||
cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
|
||||
cmd.Env = append(
|
||||
os.Environ(),
|
||||
"GITEA_PREFIX_SRC="+baseLinkSrc,
|
||||
@@ -151,7 +162,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
|
||||
process.SetSysProcAttribute(cmd)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String())
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), cmdProg, shellquote.Join(cmdArgs...), err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package external
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrepareExternalCommand(t *testing.T) {
|
||||
r := &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: ""}}
|
||||
_, _, err := r.prepareExternalCommand(map[string]string{"KEY": "val"})
|
||||
assert.ErrorContains(t, err, "no command")
|
||||
|
||||
r = &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: `"/foo bar/bin" --opt $KEY "$KEY" %KEY% other`}}
|
||||
prog, args, err := r.prepareExternalCommand(map[string]string{"KEY": `a"b`})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "/foo bar/bin", prog)
|
||||
assert.Equal(t, []string{"--opt", `a"b`, `a"b`, `a"b`, "other"}, args)
|
||||
}
|
||||
Vendored
+1
-8
@@ -21,19 +21,12 @@ type frontendRenderer struct {
|
||||
patterns []string
|
||||
}
|
||||
|
||||
var (
|
||||
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
|
||||
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
)
|
||||
var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
|
||||
func (p *frontendRenderer) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) NeedPostProcess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) FileNamePatterns() []string {
|
||||
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
|
||||
// There are some approaches to make it more accurate, but they are all complicated:
|
||||
|
||||
@@ -29,9 +29,8 @@ func init() {
|
||||
type renderer struct{}
|
||||
|
||||
var (
|
||||
_ markup.Renderer = (*renderer)(nil)
|
||||
_ markup.PostProcessRenderer = (*renderer)(nil)
|
||||
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
||||
_ markup.Renderer = (*renderer)(nil)
|
||||
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
||||
)
|
||||
|
||||
type mimeHandler struct {
|
||||
@@ -96,8 +95,6 @@ func (renderer) Name() string {
|
||||
return "jupyter-render"
|
||||
}
|
||||
|
||||
func (renderer) NeedPostProcess() bool { return true }
|
||||
|
||||
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
|
||||
return markup.ExternalRendererOptions{
|
||||
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
|
||||
@@ -215,7 +212,7 @@ func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) erro
|
||||
|
||||
// Highlight code
|
||||
lexer := highlight.DetectChromaLexerByFileName("", language)
|
||||
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(language))
|
||||
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(lexer.Config().Name))
|
||||
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
|
||||
output.WriteHTML("</code></pre></div>")
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
maliciousNotebook := `{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2,
|
||||
"metadata": {},
|
||||
"metadata": {"language_info":{"name":"any lang"}},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
@@ -274,7 +274,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
"execution_count": 1,
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div><script>alert('XSS Vector')</script><table class=\"dataframe\"><tr><td>Safe Content</td></tr></table></div>"
|
||||
"<div><script>foo</script><table class=other><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>"
|
||||
]
|
||||
},
|
||||
"metadata": {}
|
||||
@@ -295,8 +295,8 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">In [1]:</div>
|
||||
<div class="cell-right cell-input">
|
||||
<pre><code class="chroma language-python">
|
||||
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>
|
||||
<pre><code class="chroma language-fallback">
|
||||
a=1
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,7 +304,7 @@ func TestIntegrationAndSanitization(t *testing.T) {
|
||||
<div class="cell-left cell-prompt">Out [1]:</div>
|
||||
<div class="cell-right cell-output">
|
||||
<div class="cell-output-html">
|
||||
<div><table><tbody><tr><td>Safe Content</td></tr></tbody></table></div>
|
||||
<div><table><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
func TestMain(m *testing.M) {
|
||||
setting.IsInTesting = true
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
setting.Markdown.FileNamePatterns = []string{"*.md"}
|
||||
markup.RefreshFileNamePatterns()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
|
||||
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
|
||||
htmlWriter.ExtendingWriter = w
|
||||
|
||||
res, err := org.New().Silent().Parse(input, "").Write(w)
|
||||
cfg := org.New()
|
||||
cfg.ReadFile = func(path string) ([]byte, error) {
|
||||
// actually the orgmode render doesn't support rendering the content from the content again,
|
||||
// so just leave the plain text to end users
|
||||
content := fmt.Sprintf("#+INCLUDE: [[%s]]", path)
|
||||
return []byte(content), nil
|
||||
}
|
||||
doc := cfg.Silent().Parse(input, "")
|
||||
res, err := doc.Write(w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orgmode.Render failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
func testRender(t *testing.T, input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[https://google.com/]]",
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
testRender(t, "[[https://google.com/]]",
|
||||
`<p><a href="https://google.com/">https://google.com/</a></p>`)
|
||||
test("[[ImageLink.svg][The Image Desc]]",
|
||||
testRender(t, "[[ImageLink.svg][The Image Desc]]",
|
||||
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_InternalLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:test.org][Test]]",
|
||||
testRender(t, "[[file:test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[./test.org][Test]]",
|
||||
testRender(t, "[[./test.org][Test]]",
|
||||
`<p><a href="./test.org">Test</a></p>`)
|
||||
test("[[test.org][Test]]",
|
||||
testRender(t, "[[test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[path/to/test.org][Test]]",
|
||||
testRender(t, "[[path/to/test.org][Test]]",
|
||||
`<p><a href="path/to/test.org">Test</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Media(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:../../.images/src/02/train.jpg]]",
|
||||
testRender(t, "[[file:../../.images/src/02/train.jpg]]",
|
||||
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
|
||||
test("[[file:train.jpg]]",
|
||||
testRender(t, "[[file:train.jpg]]",
|
||||
`<p><img src="train.jpg" alt="train.jpg"></p>`)
|
||||
|
||||
// With description.
|
||||
test("[[https://example.com][https://example.com/example.svg]]",
|
||||
testRender(t, "[[https://example.com][https://example.com/example.svg]]",
|
||||
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
testRender(t, "[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
|
||||
test("[[https://example.com][https://example.com/example.mp4]]",
|
||||
testRender(t, "[[https://example.com][https://example.com/example.mp4]]",
|
||||
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
testRender(t, "[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
|
||||
|
||||
// Without description.
|
||||
test("[[https://example.com/example.svg]]",
|
||||
testRender(t, "[[https://example.com/example.svg]]",
|
||||
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
|
||||
test("[[https://example.com/example.mp4]]",
|
||||
testRender(t, "[[https://example.com/example.mp4]]",
|
||||
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
|
||||
|
||||
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
|
||||
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
testRender(t, `[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
|
||||
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
testRender(t, `[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
|
||||
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
testRender(t, `[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
testRender(t, `[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Source(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test(`#+begin_src c
|
||||
testRender(t, `#+begin_src c
|
||||
int a;
|
||||
#+end_src
|
||||
`, `<div class="src src-c">
|
||||
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
|
||||
</div>`)
|
||||
}
|
||||
|
||||
func TestRender_IncludeLink(t *testing.T) {
|
||||
testRender(t, `#+INCLUDE: "./other.org" src text`, `<div class="src src-text">
|
||||
<pre><code class="chroma language-plaintext">#+INCLUDE: [[other.org]]</code></pre>
|
||||
</div>`)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMissingPKGINFOFile = util.NewInvalidArgumentErrorf("PKGINFO file is missing")
|
||||
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
|
||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
||||
ErrMissingPKGINFOFile = util.NewInvalidArgumentErrorf("PKGINFO file is missing")
|
||||
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid")
|
||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
||||
ErrPackageInfoTooLarge = util.NewInvalidArgumentErrorf("PKGINFO contains too many entries")
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -36,6 +37,8 @@ const (
|
||||
RepositoryVersion = "_repository"
|
||||
|
||||
NoArch = "noarch"
|
||||
|
||||
maxPackageInfoEntries = 1024
|
||||
)
|
||||
|
||||
// https://wiki.alpinelinux.org/wiki/Apk_spec
|
||||
@@ -185,10 +188,16 @@ func ParsePackageInfo(r io.Reader) (*Package, error) {
|
||||
p.FileMetadata.InstallIf = value
|
||||
case "provides":
|
||||
if value != "" {
|
||||
if len(p.FileMetadata.Provides)+len(p.FileMetadata.Dependencies) >= maxPackageInfoEntries {
|
||||
return nil, ErrPackageInfoTooLarge
|
||||
}
|
||||
p.FileMetadata.Provides = append(p.FileMetadata.Provides, value)
|
||||
}
|
||||
case "depend":
|
||||
if value != "" {
|
||||
if len(p.FileMetadata.Provides)+len(p.FileMetadata.Dependencies) >= maxPackageInfoEntries {
|
||||
return nil, ErrPackageInfoTooLarge
|
||||
}
|
||||
p.FileMetadata.Dependencies = append(p.FileMetadata.Dependencies, value)
|
||||
}
|
||||
case "provider_priority":
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -97,6 +98,15 @@ func TestParsePackage(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "Q1SRYURM5+uQDqfHSwTnNIOIuuDVQ=", p.FileMetadata.Checksum)
|
||||
})
|
||||
|
||||
t.Run("TooManyDependencyEntries", func(t *testing.T) {
|
||||
data := append(createPKGINFOContent(packageName, packageVersion), []byte("\ndepend = item")...)
|
||||
data = append(data, []byte(strings.Repeat("\ndepend = item", maxPackageInfoEntries))...)
|
||||
|
||||
p, err := ParsePackageInfo(bytes.NewReader(data))
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrPackageInfoTooLarge)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParsePackageInfo(t *testing.T) {
|
||||
|
||||
@@ -153,6 +153,9 @@ func ParsePackage(r io.Reader) (*Package, error) {
|
||||
return nil, err
|
||||
}
|
||||
} else if !strings.HasPrefix(filename, ".") {
|
||||
if strings.ContainsAny(hd.Name, "\n\r") {
|
||||
continue // a newline would forge extra lines in the pacman index
|
||||
}
|
||||
if err := files.Add(hd.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func TestParsePackage(t *testing.T) {
|
||||
data := createPackage(c, map[string][]byte{
|
||||
".PKGINFO": createPKGINFOContent(packageName, packageVersion),
|
||||
"/test/dummy.txt": {},
|
||||
"usr/lib/legit\n\n%FILES%\n/etc/cron.d/x": {}, // must not reach the file list
|
||||
})
|
||||
|
||||
p, err := ParsePackage(data)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
var (
|
||||
ErrMissingManifestFile = util.NewInvalidArgumentErrorf("Package.swift file is missing")
|
||||
ErrManifestFileTooLarge = util.NewInvalidArgumentErrorf("Package.swift file is too large")
|
||||
ErrManifestFilesTooLarge = util.NewInvalidArgumentErrorf("Package.swift files are too large")
|
||||
ErrInvalidManifestVersion = util.NewInvalidArgumentErrorf("manifest version is invalid")
|
||||
|
||||
manifestPattern = regexp.MustCompile(`\APackage(?:@swift-(\d+(?:\.\d+)?(?:\.\d+)?))?\.swift\z`)
|
||||
@@ -29,6 +30,8 @@ var (
|
||||
|
||||
const (
|
||||
maxManifestFileSize = 128 * 1024
|
||||
maxManifestFiles = 64
|
||||
maxManifestSize = maxManifestFiles * maxManifestFileSize
|
||||
|
||||
PropertyScope = "swift.scope"
|
||||
PropertyName = "swift.name"
|
||||
@@ -123,11 +126,36 @@ func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) {
|
||||
},
|
||||
}
|
||||
|
||||
// Nested packages (test fixtures, examples, benchmarks) ship their own manifests, which must not
|
||||
// replace the package manifest. The package sits at the archive root or in a single top level
|
||||
// directory, so keep only the shallowest manifest directory, breaking ties by name for stability.
|
||||
var manifestFiles []*zip.File
|
||||
manifestDir, manifestDepth := "", 0
|
||||
for _, file := range zr.File {
|
||||
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
||||
if len(manifestMatch) == 0 {
|
||||
if strings.HasSuffix(file.Name, "/") || !manifestPattern.MatchString(path.Base(file.Name)) {
|
||||
continue
|
||||
}
|
||||
dir, depth := path.Dir(file.Name), strings.Count(file.Name, "/")
|
||||
switch {
|
||||
case manifestFiles == nil || depth < manifestDepth || (depth == manifestDepth && dir < manifestDir):
|
||||
manifestDir, manifestDepth, manifestFiles = dir, depth, []*zip.File{file}
|
||||
case dir == manifestDir:
|
||||
manifestFiles = append(manifestFiles, file)
|
||||
}
|
||||
}
|
||||
if len(manifestFiles) > maxManifestFiles {
|
||||
return nil, ErrManifestFilesTooLarge
|
||||
}
|
||||
var manifestSize uint64
|
||||
for _, file := range manifestFiles {
|
||||
manifestSize += file.UncompressedSize64
|
||||
}
|
||||
if manifestSize > maxManifestSize {
|
||||
return nil, ErrManifestFilesTooLarge
|
||||
}
|
||||
|
||||
for _, file := range manifestFiles {
|
||||
manifestMatch := manifestPattern.FindStringSubmatch(path.Base(file.Name))
|
||||
|
||||
if file.UncompressedSize64 > maxManifestFileSize {
|
||||
return nil, ErrManifestFileTooLarge
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package swift
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -24,6 +26,18 @@ const (
|
||||
packageLicense = "MIT"
|
||||
)
|
||||
|
||||
// writeOrderedZipArchive writes name/content pairs in the given order, which map based test.WriteZipArchive cannot do
|
||||
func writeOrderedZipArchive(entries [][2]string) *bytes.Buffer {
|
||||
buf := &bytes.Buffer{}
|
||||
zw := zip.NewWriter(buf)
|
||||
for _, entry := range entries {
|
||||
w, _ := zw.Create(entry[0])
|
||||
_, _ = w.Write([]byte(entry[1]))
|
||||
}
|
||||
_ = zw.Close()
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestParsePackage(t *testing.T) {
|
||||
t.Run("MissingManifestFile", func(t *testing.T) {
|
||||
data := test.WriteZipArchive(map[string]string{"dummy.txt": ""})
|
||||
@@ -41,6 +55,19 @@ func TestParsePackage(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrManifestFileTooLarge)
|
||||
})
|
||||
|
||||
t.Run("TooManyManifestFiles", func(t *testing.T) {
|
||||
entries := make([][2]string, 0, maxManifestFiles+1)
|
||||
entries = append(entries, [2]string{"Package.swift", "// swift-tools-version:5.7"})
|
||||
for i := range maxManifestFiles {
|
||||
entries = append(entries, [2]string{fmt.Sprintf("Package@swift-5.%d.swift", i), "// swift-tools-version:5.7"})
|
||||
}
|
||||
|
||||
data := writeOrderedZipArchive(entries)
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrManifestFilesTooLarge)
|
||||
})
|
||||
|
||||
t.Run("WithoutMetadata", func(t *testing.T) {
|
||||
content1 := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||
content2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift"
|
||||
@@ -65,6 +92,77 @@ func TestParsePackage(t *testing.T) {
|
||||
assert.Equal(t, content2, m.Content)
|
||||
})
|
||||
|
||||
t.Run("IgnoresNestedManifests", func(t *testing.T) {
|
||||
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||
rootAltManifest := "// swift-tools-version:5.5\n//\n// Package@swift-5.5.swift"
|
||||
rootPatchAltManifest := "// swift-tools-version:5.7.1\n//\n// Package@swift-5.7.1.swift"
|
||||
nestedManifest := "// swift-tools-version:6.3\n//\n// nested fixture package"
|
||||
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"Package.swift", rootManifest},
|
||||
{"Package@swift-5.5.swift", rootAltManifest},
|
||||
{"Package@swift-5.7.1.swift", rootPatchAltManifest},
|
||||
{"Benchmarks/Package.swift", nestedManifest},
|
||||
{"Utils/Fixtures/PlainPackage/Package.swift", nestedManifest},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, p.Metadata.Manifests, 3)
|
||||
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
|
||||
assert.Equal(t, "5.7", p.Metadata.Manifests[""].ToolsVersion)
|
||||
assert.Equal(t, rootAltManifest, p.Metadata.Manifests["5.5"].Content)
|
||||
assert.Equal(t, rootPatchAltManifest, p.Metadata.Manifests["5.7.1"].Content)
|
||||
})
|
||||
|
||||
t.Run("IgnoresNestedManifestsInPrefixedArchive", func(t *testing.T) {
|
||||
rootManifest := "// swift-tools-version:5.7\n//\n// Package.swift"
|
||||
|
||||
// `swift package archive-source` produces archives with a single top level directory
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"gitea-1.0.1/Package.swift", rootManifest},
|
||||
{"gitea-1.0.1/Tests/Fixtures/Package.swift", "// swift-tools-version:6.3"},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, p.Metadata.Manifests, 1)
|
||||
assert.Equal(t, rootManifest, p.Metadata.Manifests[""].Content)
|
||||
})
|
||||
|
||||
t.Run("AltManifestOnlyInRootDirectory", func(t *testing.T) {
|
||||
// a deeper Package.swift belongs to a nested package and must not stand in for the missing root manifest
|
||||
data := test.WriteZipArchive(map[string]string{
|
||||
"Package@swift-5.5.swift": "// swift-tools-version:5.5",
|
||||
"Sub/Package.swift": "// swift-tools-version:5.7",
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.Nil(t, p)
|
||||
assert.ErrorIs(t, err, ErrMissingManifestFile)
|
||||
})
|
||||
|
||||
t.Run("ManifestDirectoryTieBreak", func(t *testing.T) {
|
||||
contentA := "// swift-tools-version:5.7\n// A"
|
||||
contentB := "// swift-tools-version:5.7\n// B"
|
||||
|
||||
// at equal depth the name decides, never the archive order
|
||||
data := writeOrderedZipArchive([][2]string{
|
||||
{"a/Package.swift", contentA},
|
||||
{"b/Package.swift", contentB},
|
||||
})
|
||||
|
||||
p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil)
|
||||
assert.NotNil(t, p)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, p.Metadata.Manifests, 1)
|
||||
assert.Equal(t, contentA, p.Metadata.Manifests[""].Content)
|
||||
})
|
||||
|
||||
t.Run("WithMetadata", func(t *testing.T) {
|
||||
data := test.WriteZipArchive(map[string]string{
|
||||
"Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift",
|
||||
|
||||
@@ -109,18 +109,6 @@ func HookProcReceive(ctx context.Context, ownerName, repoName string, opts HookO
|
||||
return requestJSONResp(req, &HookProcReceiveResult{})
|
||||
}
|
||||
|
||||
// SetDefaultBranch will set the default branch to the provided branch for the provided repository
|
||||
func SetDefaultBranch(ctx context.Context, ownerName, repoName, branch string) ResponseExtra {
|
||||
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/hook/set-default-branch/%s/%s/%s",
|
||||
url.PathEscape(ownerName),
|
||||
url.PathEscape(repoName),
|
||||
url.PathEscape(branch),
|
||||
)
|
||||
req := newInternalRequestAPI(ctx, reqURL, "POST")
|
||||
_, extra := requestJSONResp(req, &ResponseText{})
|
||||
return extra
|
||||
}
|
||||
|
||||
// SSHLog sends ssh error log response
|
||||
func SSHLog(ctx context.Context, isErr bool, msg string) error {
|
||||
reqURL := setting.LocalURL + "api/internal/ssh/log"
|
||||
|
||||
@@ -88,25 +88,6 @@ type LoggerOptions struct {
|
||||
Config map[string]any
|
||||
}
|
||||
|
||||
// AddLogger adds a logger
|
||||
func AddLogger(ctx context.Context, logger, writer, mode string, config map[string]any) ResponseExtra {
|
||||
reqURL := setting.LocalURL + "api/internal/manager/add-logger"
|
||||
req := newInternalRequestAPI(ctx, reqURL, "POST", LoggerOptions{
|
||||
Logger: logger,
|
||||
Writer: writer,
|
||||
Mode: mode,
|
||||
Config: config,
|
||||
})
|
||||
return requestJSONClientMsg(req, "Added")
|
||||
}
|
||||
|
||||
// RemoveLogger removes a logger
|
||||
func RemoveLogger(ctx context.Context, logger, writer string) ResponseExtra {
|
||||
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/remove-logger/%s/%s", url.PathEscape(logger), url.PathEscape(writer))
|
||||
req := newInternalRequestAPI(ctx, reqURL, "POST")
|
||||
return requestJSONClientMsg(req, "Removed")
|
||||
}
|
||||
|
||||
// Processes return the current processes from this gitea instance
|
||||
func Processes(ctx context.Context, out io.Writer, flat, noSystem, stacktraces, json bool, cancel string) ResponseExtra {
|
||||
reqURL := setting.LocalURL + fmt.Sprintf("api/internal/manager/processes?flat=%t&no-system=%t&stacktraces=%t&json=%t&cancel-pid=%s", flat, noSystem, stacktraces, json, url.QueryEscape(cancel))
|
||||
|
||||
@@ -8,4 +8,10 @@ const (
|
||||
KeyUname = "uname"
|
||||
|
||||
KeyUserHasTwoFactorAuth = "userHasTwoFactorAuth"
|
||||
|
||||
// KeySignInMethod records how the current session was authenticated so logout
|
||||
// can decide whether RP-initiated OIDC logout is appropriate.
|
||||
KeySignInMethod = "signInMethod"
|
||||
|
||||
SignInMethodOAuth2 = "oauth2"
|
||||
)
|
||||
|
||||
@@ -50,7 +50,8 @@ var Markdown = struct {
|
||||
MathCodeBlockDetection []string
|
||||
MathCodeBlockOptions MarkdownMathCodeBlockOptions `ini:"-"`
|
||||
}{
|
||||
EnableMath: true,
|
||||
EnableMath: true,
|
||||
FileNamePatterns: []string{"*.md"},
|
||||
}
|
||||
|
||||
// MarkupRenderer defines the external parser configured in ini
|
||||
|
||||
@@ -108,7 +108,7 @@ var OAuth2 = struct {
|
||||
JWTSigningAlgorithm: "RS256",
|
||||
JWTSigningPrivateKeyFile: "jwt/private.pem",
|
||||
MaxTokenLength: math.MaxInt16,
|
||||
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
|
||||
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app"},
|
||||
}
|
||||
|
||||
func loadOAuth2From(rootCfg ConfigProvider) {
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestGetGeneralSigningSecretSave(t *testing.T) {
|
||||
func TestOauth2DefaultApplications(t *testing.T) {
|
||||
cfg, _ := NewConfigProviderFromData(``)
|
||||
loadOAuth2From(cfg)
|
||||
assert.Equal(t, []string{"git-credential-oauth", "git-credential-manager", "tea"}, OAuth2.DefaultApplications)
|
||||
assert.Equal(t, []string{"git-credential-oauth", "git-credential-manager", "tea", "gitea-app"}, OAuth2.DefaultApplications)
|
||||
|
||||
cfg, _ = NewConfigProviderFromData(`[oauth2]
|
||||
DEFAULT_APPLICATIONS = tea
|
||||
|
||||
@@ -304,12 +304,10 @@ func (a *AzureBlobStorage) ServeDirectURL(storePath, name, method string, reqPar
|
||||
|
||||
// IterateObjects iterates across the objects in the azureblobstorage
|
||||
func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, obj Object) error) error {
|
||||
dirName = a.buildAzureBlobPath(dirName)
|
||||
if dirName != "" {
|
||||
dirName += "/"
|
||||
}
|
||||
basePrefix := buildObjectStorePathPrefix(a.cfg.BasePath, "")
|
||||
dirPrefix := buildObjectStorePathPrefix(a.cfg.BasePath, dirName)
|
||||
pager := a.client.NewListBlobsFlatPager(a.cfg.Container, &container.ListBlobsFlatOptions{
|
||||
Prefix: &dirName,
|
||||
Prefix: &dirPrefix,
|
||||
})
|
||||
for pager.More() {
|
||||
resp, err := pager.NextPage(a.ctx)
|
||||
@@ -317,7 +315,8 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
return convertAzureBlobErr(err)
|
||||
}
|
||||
for _, object := range resp.Segment.BlobItems {
|
||||
blobClient := a.getBlobClient(*object.Name)
|
||||
objPath := strings.TrimPrefix(*object.Name, basePrefix)
|
||||
blobClient := a.getBlobClient(objPath)
|
||||
object := &azureBlobObject{
|
||||
Context: a.ctx,
|
||||
blobClient: blobClient,
|
||||
@@ -327,7 +326,7 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
}
|
||||
if err := func(object *azureBlobObject, fn func(path string, obj Object) error) error {
|
||||
defer object.Close()
|
||||
return fn(strings.TrimPrefix(object.Name, a.cfg.BasePath), object)
|
||||
return fn(objPath, object)
|
||||
}(object, fn); err != nil {
|
||||
return convertAzureBlobErr(err)
|
||||
}
|
||||
@@ -336,7 +335,6 @@ func (a *AzureBlobStorage) IterateObjects(dirName string, fn func(path string, o
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete delete a file
|
||||
func (a *AzureBlobStorage) getBlobClient(path string) *blob.Client {
|
||||
return a.client.ServiceClient().NewContainerClient(a.cfg.Container).NewBlobClient(a.buildAzureBlobPath(path))
|
||||
}
|
||||
|
||||
@@ -27,24 +27,16 @@ func TestAzureBlobStorage(t *testing.T) {
|
||||
Container: "test",
|
||||
},
|
||||
}
|
||||
table := []struct {
|
||||
name string
|
||||
test func(t *testing.T, typStr Type, cfg *setting.Storage)
|
||||
}{
|
||||
{
|
||||
name: "iterator",
|
||||
test: testStorageIterator,
|
||||
},
|
||||
{
|
||||
name: "testBlobStorageURLContentTypeAndDisposition",
|
||||
test: testBlobStorageURLContentTypeAndDisposition,
|
||||
},
|
||||
}
|
||||
for _, entry := range table {
|
||||
t.Run(entry.name, func(t *testing.T) {
|
||||
entry.test(t, storageType, config)
|
||||
})
|
||||
}
|
||||
t.Run("Iterator", func(t *testing.T) {
|
||||
testStorageIterator(t, storageType, config)
|
||||
})
|
||||
t.Run("BlobStorageURLContentTypeAndDisposition", func(t *testing.T) {
|
||||
testBlobStorageURLContentTypeAndDisposition(t, storageType, config)
|
||||
})
|
||||
t.Run("IteratorWithBasePath", func(t *testing.T) {
|
||||
config.AzureBlobConfig.BasePath = "test-base-path"
|
||||
testStorageIterator(t, storageType, config)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAzureBlobStoragePath(t *testing.T) {
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
|
||||
var _ ObjectStorage = &MinioStorage{}
|
||||
|
||||
const unknownSizePartSize = 1024 * 1024 * 16 // same as minio-go's minPartSize
|
||||
|
||||
type minioObject struct {
|
||||
*minio.Object
|
||||
}
|
||||
@@ -211,6 +213,10 @@ func (m *MinioStorage) Save(path string, r io.Reader, size int64) (int64, error)
|
||||
// * https://www.backblaze.com/b2/docs/s3_compatible_api.html
|
||||
// do not support "x-amz-checksum-algorithm" header, so use legacy MD5 checksum
|
||||
SendContentMd5: m.cfg.ChecksumAlgorithm == "md5",
|
||||
|
||||
// with an unknown size (-1) minio-go assumes a 5TiB object and buffers a 528MiB part for it, even
|
||||
// for a payload of a few KiB, so pin the part size there, a known size derives its own
|
||||
PartSize: util.Iif[uint64](size < 0, unknownSizePartSize, 0),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// ErrURLNotSupported represents url is not supported
|
||||
@@ -139,6 +141,23 @@ func SaveFrom(objStorage ObjectStorage, path string, callback func(w io.Writer)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildObjectStorePath(base, p string) string {
|
||||
p = strings.TrimPrefix(util.PathJoinRelX(base, p), "/") // object store doesn't use slash for root path
|
||||
if p == "." {
|
||||
p = "" // object store doesn't use dot as relative path
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func buildObjectStorePathPrefix(base, p string) string {
|
||||
// ending slash is required for avoiding matching like "foo/" and "foobar/" with prefix "foo"
|
||||
p = buildObjectStorePath(base, p) + "/"
|
||||
if p == "/" {
|
||||
p = "" // object store doesn't use slash for root path
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
var (
|
||||
// Attachments represents attachments storage
|
||||
Attachments ObjectStorage = uninitializedStorage
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -31,6 +32,11 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||
_, err = l.Save(f[0], strings.NewReader(f[1]), -1)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
defer func() {
|
||||
for _, f := range testFiles {
|
||||
_ = l.Delete(f[0])
|
||||
}
|
||||
}()
|
||||
|
||||
expectedList := map[string][]string{
|
||||
"a": {"a/1.txt"},
|
||||
@@ -43,7 +49,9 @@ func testStorageIterator(t *testing.T, typStr Type, cfg *setting.Storage) {
|
||||
for dir, expected := range expectedList {
|
||||
count := 0
|
||||
err = l.IterateObjects(dir, func(path string, f Object) error {
|
||||
defer f.Close()
|
||||
content, err := io.ReadAll(f)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, content)
|
||||
assert.Contains(t, expected, path)
|
||||
count++
|
||||
return nil
|
||||
|
||||
@@ -11,7 +11,6 @@ type CreateUserOption struct {
|
||||
// The authentication source ID to associate with the user
|
||||
SourceID int64 `json:"source_id"`
|
||||
// identifier of the user, provided by the external authenticator (if configured)
|
||||
// default: empty
|
||||
LoginName string `json:"login_name"`
|
||||
// username of the user
|
||||
// required: true
|
||||
@@ -44,9 +43,7 @@ type EditUserOption struct {
|
||||
// The authentication source ID to associate with the user
|
||||
SourceID int64 `json:"source_id"`
|
||||
// identifier of the user, provided by the external authenticator (if configured)
|
||||
// default: empty
|
||||
// required: true
|
||||
LoginName string `json:"login_name" binding:"Required"`
|
||||
LoginName *string `json:"login_name"`
|
||||
// swagger:strfmt email
|
||||
// The email address of the user
|
||||
Email *string `json:"email" binding:"MaxSize(254)"`
|
||||
|
||||
@@ -154,7 +154,7 @@ type CreateRepoOption struct {
|
||||
// Whether the repository is template
|
||||
Template bool `json:"template"`
|
||||
// Gitignores to use
|
||||
Gitignores string `json:"gitignores"`
|
||||
Gitignores string `json:"gitignores" binding:"MaxSize(1024)"`
|
||||
// License to use
|
||||
License string `json:"license" binding:"MaxSize(100)"`
|
||||
// Readme of the repository to create
|
||||
|
||||
@@ -18,7 +18,6 @@ type User struct {
|
||||
// login of the user, same as `username`
|
||||
UserName string `json:"login"`
|
||||
// identifier of the user, provided by the external authenticator (if configured)
|
||||
// default: empty
|
||||
LoginName string `json:"login_name"`
|
||||
// The ID of the user's Authentication Source
|
||||
SourceID int64 `json:"source_id"`
|
||||
|
||||
@@ -370,7 +370,7 @@ func (ut *RenderUtils) AvatarStackPushCommit(pushCommit *repository.PushCommit)
|
||||
// there is no way to know the real committer, but the field can't be nil
|
||||
Committer: &git.Signature{Name: pushCommit.AuthorName, Email: pushCommit.AuthorEmail},
|
||||
}
|
||||
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllParticipantIdentities(), nil)
|
||||
data := user_model.BuildAvatarStackData(ut.ctx, fakeGitCommit.AllAuthorIdentities(), nil)
|
||||
return ut.AvatarStack(data)
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ func (ut *RenderUtils) participantNameLink(data *user_model.AvatarStackData, par
|
||||
if participant.GitIdentity.Email != "" {
|
||||
return htmlutil.HTMLFormat(`<a class="muted" href="mailto:%s">%s</a>`, participant.GitIdentity.Email, participant.GitIdentity.Name)
|
||||
}
|
||||
return template.HTML(template.HTMLEscapeString(participant.GitIdentity.Name))
|
||||
return htmlutil.HTMLFormat(`<span class="avatar-stack-name">%s</span>`, participant.GitIdentity.Name)
|
||||
}
|
||||
|
||||
func (ut *RenderUtils) participantPopupRow(data *user_model.AvatarStackData, participant *user_model.CommitParticipant) template.HTML {
|
||||
|
||||
@@ -26,6 +26,11 @@ func IsEmptyString(s string) bool {
|
||||
return len(strings.TrimSpace(s)) == 0
|
||||
}
|
||||
|
||||
// ParseYamlBool parses YAML 1.2 boolean values into bool
|
||||
func ParseYamlBool(s string) bool {
|
||||
return s == "true" || s == "True" || s == "TRUE"
|
||||
}
|
||||
|
||||
// NormalizeEOL will convert Windows (CRLF) and Mac (CR) EOLs to UNIX (LF)
|
||||
func NormalizeEOL(input []byte) []byte {
|
||||
var right, left, pos int
|
||||
@@ -311,3 +316,9 @@ func DiffSlice[T comparable](oldSlice, newSlice []T) (added, removed []T) {
|
||||
}
|
||||
return added, removed
|
||||
}
|
||||
|
||||
func MustNoError(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3742,7 +3742,7 @@
|
||||
"actions.runners.runner_title": "Runner",
|
||||
"actions.runners.task_list": "Recent tasks on this runner",
|
||||
"actions.runners.task_list.no_tasks": "There is no task yet.",
|
||||
"actions.runners.task_list.run": "Run",
|
||||
"actions.runners.task_list.job": "Job",
|
||||
"actions.runners.task_list.status": "Status",
|
||||
"actions.runners.task_list.repository": "Repository",
|
||||
"actions.runners.task_list.commit": "Commit",
|
||||
@@ -3807,6 +3807,7 @@
|
||||
"actions.runs.cancel": "Cancel workflow run",
|
||||
"actions.runs.delete.description": "Are you sure you want to permanently delete this workflow run? This action cannot be undone.",
|
||||
"actions.runs.not_done": "This workflow run is not done.",
|
||||
"actions.runs.no_failed_jobs": "This workflow run has no failed jobs to re-run.",
|
||||
"actions.runs.view_workflow_file": "View workflow file",
|
||||
"actions.runs.summary": "Summary",
|
||||
"actions.runs.all_jobs": "All jobs",
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@
|
||||
"jquery": "4.0.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"katex": "0.17.0",
|
||||
"mermaid": "11.15.0",
|
||||
"mermaid": "11.16.1",
|
||||
"online-3d-viewer": "0.18.0",
|
||||
"pdfobject": "2.3.1",
|
||||
"perfect-debounce": "2.1.0",
|
||||
|
||||
Generated
+12
-12
@@ -73,7 +73,7 @@ importers:
|
||||
version: 0.1.0-rc2
|
||||
'@mermaid-js/layout-elk':
|
||||
specifier: 0.2.1
|
||||
version: 0.2.1(mermaid@11.15.0)
|
||||
version: 0.2.1(mermaid@11.16.1)
|
||||
'@primer/octicons':
|
||||
specifier: 19.28.1
|
||||
version: 19.28.1
|
||||
@@ -147,8 +147,8 @@ importers:
|
||||
specifier: 0.17.0
|
||||
version: 0.17.0
|
||||
mermaid:
|
||||
specifier: 11.15.0
|
||||
version: 11.15.0
|
||||
specifier: 11.16.1
|
||||
version: 11.16.1
|
||||
online-3d-viewer:
|
||||
specifier: 0.18.0
|
||||
version: 0.18.0
|
||||
@@ -942,8 +942,8 @@ packages:
|
||||
peerDependencies:
|
||||
mermaid: ^11.0.2
|
||||
|
||||
'@mermaid-js/parser@1.1.1':
|
||||
resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6':
|
||||
resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
|
||||
@@ -3301,8 +3301,8 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
mermaid@11.15.0:
|
||||
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
|
||||
mermaid@11.16.1:
|
||||
resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
|
||||
|
||||
micromark-core-commonmark@2.0.3:
|
||||
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
|
||||
@@ -5179,13 +5179,13 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@mermaid-js/layout-elk@0.2.1(mermaid@11.15.0)':
|
||||
'@mermaid-js/layout-elk@0.2.1(mermaid@11.16.1)':
|
||||
dependencies:
|
||||
d3: 7.9.0
|
||||
elkjs: 0.9.3
|
||||
mermaid: 11.15.0
|
||||
mermaid: 11.16.1
|
||||
|
||||
'@mermaid-js/parser@1.1.1':
|
||||
'@mermaid-js/parser@1.2.0':
|
||||
dependencies:
|
||||
'@chevrotain/types': 11.1.2
|
||||
|
||||
@@ -7764,11 +7764,11 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
mermaid@11.15.0:
|
||||
mermaid@11.16.1:
|
||||
dependencies:
|
||||
'@braintree/sanitize-url': 7.1.2
|
||||
'@iconify/utils': 3.1.3
|
||||
'@mermaid-js/parser': 1.1.1
|
||||
'@mermaid-js/parser': 1.2.0
|
||||
'@types/d3': 7.4.3
|
||||
'@upsetjs/venn.js': 2.0.0
|
||||
cytoscape: 3.33.4
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user