diff --git a/.changelog.yml b/.changelog.yml index a7df8779de1..748676569a3 100644 --- a/.changelog.yml +++ b/.changelog.yml @@ -37,10 +37,7 @@ groups: name: BUGFIXES labels: - type/bug - - - name: API - labels: - - modifies/api + - name: TESTING labels: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f8e5972af13..4863391f394 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Gitea DevContainer", - "image": "mcr.microsoft.com/devcontainers/go:1.25-trixie", + "image": "mcr.microsoft.com/devcontainers/go:1.26-trixie", "containerEnv": { // override "local" from packaged version "GOTOOLCHAIN": "auto" diff --git a/.github/actions/go-cache/action.yml b/.github/actions/go-cache/action.yml new file mode 100644 index 00000000000..04d4bac3673 --- /dev/null +++ b/.github/actions/go-cache/action.yml @@ -0,0 +1,47 @@ +name: go-caches +description: Restore and save go module, build, and golangci-lint caches + +inputs: + cache-name: + description: Short identifier used in the per-caller build cache key + required: true + build-cache: + description: Whether to include ~/.cache/go-build + default: "true" + build-cache-rotate: + description: Whether to rotate the build cache key per run so Go's test result cache can accumulate across runs + default: "false" + lint-cache: + description: Whether to include ~/.cache/golangci-lint + default: "false" + +runs: + using: composite + steps: + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/go/pkg/mod + key: gomod-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} + restore-keys: gomod-${{ runner.os }}-${{ runner.arch }} + - if: ${{ inputs.build-cache == 'true' && inputs.build-cache-rotate == 'true' }} + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.cache/go-build + key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-name }}-${{ hashFiles('go.sum') }}-${{ github.run_id }} + restore-keys: | + gobuild-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-name }}-${{ hashFiles('go.sum') }} + gobuild-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-name }} + gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} + gobuild-${{ runner.os }}-${{ runner.arch }} + - if: ${{ inputs.build-cache == 'true' && inputs.build-cache-rotate != 'true' }} + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.cache/go-build + key: gobuild-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} + restore-keys: gobuild-${{ runner.os }}-${{ runner.arch }} + - if: ${{ inputs.lint-cache == 'true' }} + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.cache/golangci-lint + key: golangci-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-name }}-${{ hashFiles('go.sum', '.golangci.yml') }} + restore-keys: golangci-${{ runner.os }}-${{ runner.arch }}-${{ inputs.cache-name }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index be33b8975fc..00000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: github-actions - labels: [modifies/dependencies] - directory: / - schedule: - interval: daily - cooldown: - default-days: 5 diff --git a/.github/labeler.yml b/.github/labeler.yml index 0f3c5080411..937e69ef208 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,80 +1,3 @@ -modifies/docs: - - changed-files: - - any-glob-to-any-file: - - "**/*.md" - - "docs/**" - -modifies/templates: - - changed-files: - - all-globs-to-any-file: - - "templates/**" - - "!templates/swagger/v1_json.tmpl" - -modifies/api: - - changed-files: - - any-glob-to-any-file: - - "routers/api/**" - - "templates/swagger/v1_json.tmpl" - -modifies/cli: - - changed-files: - - any-glob-to-any-file: - - "cmd/**" - -modifies/translation: - - changed-files: - - any-glob-to-any-file: - - "options/locale/*.ini" - -modifies/migrations: - - changed-files: - - any-glob-to-any-file: - - "models/migrations/**" - -modifies/internal: - - changed-files: - - any-glob-to-any-file: - - ".air.toml" - - "Makefile" - - "Dockerfile" - - "Dockerfile.rootless" - - ".dockerignore" - - "docker/**" - - ".editorconfig" - - ".eslintrc.cjs" - - ".golangci.yml" - - ".markdownlint.yaml" - - ".spectral.yaml" - - "stylelint.config.*" - - ".yamllint.yaml" - - ".github/**" - - ".gitea/**" - - ".devcontainer/**" - - "build/**" - - "contrib/**" - -modifies/dependencies: - - changed-files: - - any-glob-to-any-file: - - "package.json" - - "pnpm-lock.yaml" - - "pyproject.toml" - - "uv.lock" - - "go.mod" - - "go.sum" - -modifies/go: - - changed-files: - - any-glob-to-any-file: - - "**/*.go" - -modifies/frontend: - - changed-files: - - any-glob-to-any-file: - - "*.js" - - "*.ts" - - "web_src/**" - docs-update-needed: - changed-files: - any-glob-to-any-file: diff --git a/.github/workflows/cache-seeder.yml b/.github/workflows/cache-seeder.yml new file mode 100644 index 00000000000..d0801a10783 --- /dev/null +++ b/.github/workflows/cache-seeder.yml @@ -0,0 +1,75 @@ +# Populates the go module, build, and golangci-lint caches under the default +# branch's cache scope so that PR runs have a warm fallback to restore from. +# +# GitHub Actions caches are scoped per ref: a PR run can only write to its own +# branch's scope, but can read from the base branch's scope as a fallback. +# PRs therefore cannot seed main's scope themselves. Running the same cache +# steps on push-to-main is the only opportunity to populate that fallback +# scope so fresh PR branches start with a useful cache on first run. + +# A PR job's exact key lives in its own PR-scope (empty on first run, filled +# by later runs of the same PR); on miss, actions/cache's restore-keys fall +# back to prefix matches against entries this seeder saves in main's scope. + +name: cache-seeder + +on: + push: + branches: + - main + paths: + - "go.sum" + - ".golangci.yml" + - ".github/actions/go-cache/action.yml" + - ".github/workflows/cache-seeder.yml" + +concurrency: + group: cache-seeder + cancel-in-progress: true + +jobs: + gobuild: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: seed + - run: make deps-backend + - run: TAGS="bindata" make backend + - run: TAGS="bindata sqlite sqlite_unlock_notify" make backend + - run: TAGS="bindata gogit sqlite sqlite_unlock_notify" GOEXPERIMENT="" make backend + + lint: + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - { job: lint-backend, tags: "bindata sqlite sqlite_unlock_notify", target: "lint-backend" } + - { job: lint-go-windows, tags: "bindata sqlite sqlite_unlock_notify", target: "lint-go-windows" } + - { job: lint-go-gogit, tags: "bindata sqlite sqlite_unlock_notify gogit", target: "lint-go" } + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: ${{ matrix.job }} + lint-cache: "true" + - run: make deps-backend deps-tools + - run: make ${{ matrix.target }} + env: + TAGS: ${{ matrix.tags }} diff --git a/.github/workflows/cron-flake-updater.yml b/.github/workflows/cron-flake-updater.yml deleted file mode 100644 index c9a1f22a2ae..00000000000 --- a/.github/workflows/cron-flake-updater.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: cron-flake-updater - -on: - workflow_dispatch: - schedule: - - cron: '0 0 * * 0' # runs weekly on Sunday at 00:00 - -jobs: - nix-flake-update: - permissions: - contents: write - issues: write - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: DeterminateSystems/determinate-nix-action@v3 - - uses: DeterminateSystems/update-flake-lock@main - with: - pr-title: "Update Nix flake" - pr-labels: | - dependencies diff --git a/.github/workflows/cron-licenses.yml b/.github/workflows/cron-licenses.yml index ee1c3e0c750..edb6f2e1576 100644 --- a/.github/workflows/cron-licenses.yml +++ b/.github/workflows/cron-licenses.yml @@ -12,15 +12,15 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true - run: make generate-gitignore timeout-minutes: 40 - name: push translations to repo - uses: appleboy/git-push-action@v1.2.0 + uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0 with: author_email: "teabot@gitea.io" author_name: GiteaBot diff --git a/.github/workflows/cron-renovate.yml b/.github/workflows/cron-renovate.yml new file mode 100644 index 00000000000..edeefc26ad8 --- /dev/null +++ b/.github/workflows/cron-renovate.yml @@ -0,0 +1,31 @@ +name: cron-renovate + +on: + schedule: + - cron: "0 1 * * *" # daily at 01:00 UTC + workflow_dispatch: + +concurrency: + group: cron-renovate + +env: + RENOVATE_VERSION: 43.141.5 # renovate: datasource=docker depName=ghcr.io/renovatebot/renovate + +jobs: + cron-renovate: + runs-on: ubuntu-latest + if: github.repository == 'go-gitea/gitea' # prevent running on forks + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: renovatebot/github-action@83ec54fee49ab67d9cd201084c1ff325b4b462e4 # v46.1.10 + with: + renovate-version: ${{ env.RENOVATE_VERSION }} + configurationFile: renovate.json5 + token: ${{ secrets.RENOVATE_TOKEN }} + env: + RENOVATE_BINARY_SOURCE: install # auto-install go/node toolchains needed by post-upgrade tasks. + RENOVATE_ALLOWED_POST_UPGRADE_COMMANDS: '["^make (tidy|svg)$"]' + RENOVATE_REPOSITORIES: '["go-gitea/gitea"]' diff --git a/.github/workflows/cron-translations.yml b/.github/workflows/cron-translations.yml index 56a30fb5ba6..17f29d4e0c5 100644 --- a/.github/workflows/cron-translations.yml +++ b/.github/workflows/cron-translations.yml @@ -12,8 +12,8 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 - - uses: crowdin/github-action@v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2 with: upload_sources: true upload_translations: false @@ -29,7 +29,7 @@ jobs: - name: update locales run: ./build/update-locales.sh - name: push translations to repo - uses: appleboy/git-push-action@v1.2.0 + uses: appleboy/git-push-action@3b2c8661652360dbf1afe1b319a49dbb739c39f1 # v1.2.0 with: author_email: "teabot@gitea.io" author_name: GiteaBot diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index 55d206bb0f6..5fd43e6cef1 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -21,6 +21,8 @@ on: value: ${{ jobs.detect.outputs.yaml }} json: value: ${{ jobs.detect.outputs.json }} + e2e: + value: ${{ jobs.detect.outputs.e2e }} jobs: detect: @@ -38,9 +40,10 @@ jobs: swagger: ${{ steps.changes.outputs.swagger }} yaml: ${{ steps.changes.outputs.yaml }} json: ${{ steps.changes.outputs.json }} + e2e: ${{ steps.changes.outputs.e2e }} steps: - - uses: actions/checkout@v6 - - uses: dorny/paths-filter@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: changes with: filters: | @@ -107,3 +110,8 @@ jobs: json: - "**/*.json" + + e2e: + - "tests/e2e/**" + - "tools/test-e2e.sh" + - "playwright.config.ts" diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index c93aed05f4c..b057962a21b 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -20,11 +20,16 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: lint-backend + lint-cache: "true" - run: make deps-backend deps-tools - run: make lint-backend env: @@ -37,11 +42,11 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - run: uv python install 3.14 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm @@ -57,8 +62,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - run: uv python install 3.14 - run: make deps-py - run: make lint-yaml @@ -70,11 +75,13 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml - run: make deps-frontend - run: make lint-json @@ -85,9 +92,9 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm @@ -102,8 +109,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true @@ -116,13 +123,18 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: lint-go-windows + lint-cache: "true" - run: make deps-backend deps-tools - - run: make lint-go-windows lint-go-gitea-vet + - run: make lint-go-windows env: TAGS: bindata sqlite sqlite_unlock_notify GOOS: windows @@ -135,11 +147,16 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: lint-go-gogit + lint-cache: "true" - run: make deps-backend deps-tools - run: make lint-go env: @@ -152,11 +169,16 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: checks-backend + build-cache: "false" - run: make deps-backend deps-tools - run: make --always-make checks-backend # ensure the "go-licenses" make target runs @@ -167,9 +189,9 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm @@ -187,11 +209,15 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: backend # no frontend build here as backend should be able to build # even without any frontend files - run: make deps-backend @@ -221,9 +247,9 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm @@ -238,8 +264,8 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index d168c2ecc5f..d49fc33dadd 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -42,11 +42,15 @@ jobs: ports: - "9000:9000" steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: pgsql - name: Add hosts to /etc/hosts run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts' - run: make deps-backend @@ -60,7 +64,6 @@ jobs: timeout-minutes: 50 env: TAGS: bindata gogit - RACE_ENABLED: true TEST_TAGS: gogit TEST_LDAP: 1 @@ -71,11 +74,15 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: sqlite - run: make deps-backend - run: GOEXPERIMENT='' make backend env: @@ -130,11 +137,16 @@ jobs: ports: - 10000:10000 steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: unit + build-cache-rotate: "true" - name: Add hosts to /etc/hosts run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 minio devstoreaccount1.azurite.local mysql elasticsearch meilisearch smtpimap" | sudo tee -a /etc/hosts' - run: make deps-backend @@ -142,16 +154,18 @@ jobs: env: TAGS: bindata - name: unit-tests - run: make unit-test-coverage test-check + run: make test-backend test-check env: TAGS: bindata RACE_ENABLED: true + GOTESTFLAGS: -timeout=20m GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} - name: unit-tests-gogit - run: GOEXPERIMENT='' make unit-test-coverage test-check + run: GOEXPERIMENT='' make test-backend test-check env: TAGS: bindata gogit RACE_ENABLED: true + GOTESTFLAGS: -timeout=20m GITHUB_READ_TOKEN: ${{ secrets.GITHUB_READ_TOKEN }} test-mysql: @@ -185,11 +199,15 @@ jobs: - "587:587" - "993:993" steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: mysql - name: Add hosts to /etc/hosts run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mysql elasticsearch smtpimap" | sudo tee -a /etc/hosts' - run: make deps-backend @@ -203,7 +221,6 @@ jobs: run: make test-mysql env: TAGS: bindata - RACE_ENABLED: true TEST_INDEXER_CODE_ES_URL: "http://elastic:changeme@elasticsearch:9200" test-mssql: @@ -226,11 +243,15 @@ jobs: ports: - 10000:10000 steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: mssql - name: Add hosts to /etc/hosts run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 mssql devstoreaccount1.azurite.local" | sudo tee -a /etc/hosts' - run: make deps-backend diff --git a/.github/workflows/pull-docker-dryrun.yml b/.github/workflows/pull-docker-dryrun.yml index 201825ccbaa..e0c0fff815d 100644 --- a/.github/workflows/pull-docker-dryrun.yml +++ b/.github/workflows/pull-docker-dryrun.yml @@ -20,18 +20,18 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build regular container image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 push: false cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful - name: Build rootless container image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: false diff --git a/.github/workflows/pull-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 3472d517c15..afa95870227 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -14,19 +14,24 @@ jobs: contents: read test-e2e: - if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.frontend == 'true' || needs.files-changed.outputs.e2e == 'true' needs: files-changed runs-on: ubuntu-latest permissions: contents: read steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + cache: false + - uses: ./.github/actions/go-cache + with: + cache-name: e2e + build-cache: "false" + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm diff --git a/.github/workflows/pull-labeler.yml b/.github/workflows/pull-labeler.yml index d05483e56ca..f9e2e5e07b7 100644 --- a/.github/workflows/pull-labeler.yml +++ b/.github/workflows/pull-labeler.yml @@ -15,6 +15,6 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/labeler@v6 + - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: sync-labels: true diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index eaebccd7fbe..a5fa452ef36 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -14,16 +14,16 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm @@ -35,7 +35,7 @@ jobs: TAGS: bindata sqlite sqlite_unlock_notify - name: import gpg key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v7 + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} @@ -52,7 +52,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -67,18 +67,18 @@ jobs: contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 + - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Get cleaned branch name id: clean_name run: | REF_NAME=$(echo "${{ github.ref }}" | sed -e 's/refs\/heads\///' -e 's/refs\/tags\///' -e 's/release\/v//') echo "branch=${REF_NAME}-nightly" >> "$GITHUB_OUTPUT" - - uses: docker/metadata-action@v6 + - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 id: meta with: images: |- @@ -88,7 +88,7 @@ jobs: type=raw,value=${{ steps.clean_name.outputs.branch }} annotations: | org.opencontainers.image.authors="maintainers@gitea.io" - - uses: docker/metadata-action@v6 + - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 id: meta_rootless with: images: |- @@ -102,18 +102,18 @@ jobs: annotations: | org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR using PAT - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: build regular docker image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 @@ -123,7 +123,7 @@ jobs: cache-from: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful cache-to: type=registry,ref=ghcr.io/go-gitea/gitea:buildcache-rootful,mode=max - name: build rootless docker image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 diff --git a/.github/workflows/release-tag-rc.yml b/.github/workflows/release-tag-rc.yml index 248fa532eeb..2e0f2dd5c0b 100644 --- a/.github/workflows/release-tag-rc.yml +++ b/.github/workflows/release-tag-rc.yml @@ -15,16 +15,16 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm @@ -36,7 +36,7 @@ jobs: TAGS: bindata sqlite sqlite_unlock_notify - name: import gpg key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v7 + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} @@ -53,7 +53,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -62,7 +62,7 @@ jobs: run: | aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress - name: Install GH CLI - uses: dev-hanz-ops/install-gh-cli-action@v0.2.1 + uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1 with: gh-cli-version: 2.39.1 - name: create github release @@ -77,13 +77,13 @@ jobs: contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - uses: docker/metadata-action@v6 + - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 id: meta with: images: |- @@ -96,7 +96,7 @@ jobs: type=semver,pattern={{version}} annotations: | org.opencontainers.image.authors="maintainers@gitea.io" - - uses: docker/metadata-action@v6 + - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 id: meta_rootless with: images: |- @@ -112,18 +112,18 @@ jobs: annotations: | org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR using PAT - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: build regular container image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 @@ -131,7 +131,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} annotations: ${{ steps.meta.outputs.annotations }} - name: build rootless container image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 diff --git a/.github/workflows/release-tag-version.yml b/.github/workflows/release-tag-version.yml index 1e84ae1739f..2e7a9f5f54c 100644 --- a/.github/workflows/release-tag-version.yml +++ b/.github/workflows/release-tag-version.yml @@ -18,16 +18,16 @@ jobs: contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: actions/setup-go@v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod check-latest: true - - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v6 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 cache: pnpm @@ -39,7 +39,7 @@ jobs: TAGS: bindata sqlite sqlite_unlock_notify - name: import gpg key id: import_gpg - uses: crazy-max/ghaction-import-gpg@v7 + uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 with: gpg_private_key: ${{ secrets.GPGSIGN_KEY }} passphrase: ${{ secrets.GPGSIGN_PASSPHRASE }} @@ -56,7 +56,7 @@ jobs: echo "Cleaned name is ${REF_NAME}" echo "branch=${REF_NAME}" >> "$GITHUB_OUTPUT" - name: configure aws - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: aws-region: ${{ secrets.AWS_REGION }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -65,7 +65,7 @@ jobs: run: | aws s3 sync dist/release s3://${{ secrets.AWS_S3_BUCKET }}/gitea/${{ steps.clean_name.outputs.branch }} --no-progress - name: Install GH CLI - uses: dev-hanz-ops/install-gh-cli-action@v0.2.1 + uses: dev-hanz-ops/install-gh-cli-action@af38ce09b1ec248aeb08eea2b16bbecea9e059f8 # v0.2.1 with: gh-cli-version: 2.39.1 - name: create github release @@ -80,13 +80,13 @@ jobs: contents: read packages: write # to publish to ghcr.io steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # fetch all commits instead of only the last as some branches are long lived and could have many between versions # fetch all tags to ensure that "git describe" reports expected Gitea version, eg. v1.21.0-dev-1-g1234567 - run: git fetch --unshallow --quiet --tags --force - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - uses: docker/metadata-action@v6 + - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 id: meta with: images: |- @@ -103,7 +103,7 @@ jobs: type=semver,pattern={{major}}.{{minor}} annotations: | org.opencontainers.image.authors="maintainers@gitea.io" - - uses: docker/metadata-action@v6 + - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 id: meta_rootless with: images: |- @@ -124,18 +124,18 @@ jobs: annotations: | org.opencontainers.image.authors="maintainers@gitea.io" - name: Login to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR using PAT - uses: docker/login-action@v4 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: build regular container image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 @@ -143,7 +143,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} annotations: ${{ steps.meta.outputs.annotations }} - name: build rootless container image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64,linux/riscv64 diff --git a/.golangci.yml b/.golangci.yml index afd91d65e59..570942bdd32 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -13,6 +13,7 @@ linters: - forbidigo - gocheckcompilerdirectives - gocritic + - goheader - govet - ineffassign - mirror @@ -51,6 +52,14 @@ linters: desc: do not use the go-chi cache package, use gitea's cache system - pkg: github.com/pkg/errors desc: use builtin errors package instead + migrations: + files: + - '**/models/migrations/**/*.go' + deny: + - pkg: code.gitea.io/gitea/models$ + desc: migrations must not depend on the models package + - pkg: code.gitea.io/gitea/modules/structs + desc: migrations must not depend on modules/structs (API structures change over time) nolintlint: allow-unused: false require-explanation: true @@ -109,6 +118,11 @@ linters: enable: - nilness - unusedwrite + goheader: + values: + regexp: + HEADER: '((Copyright [^\n]+|All rights reserved\.)\n)*Copyright \d{4} (The (Gogs|Gitea) Authors|Gitea Authors|Gitea)\.( All rights reserved\.)?(\n(Copyright [^\n]+|All rights reserved\.))*\nSPDX-License-Identifier: [\w.-]+' + template: '{{ HEADER }}' exclusions: generated: lax presets: @@ -158,9 +172,16 @@ issues: max-same-issues: 0 formatters: enable: - - gofmt + - gci - gofumpt settings: + gci: + custom-order: true + sections: + - standard + - prefix(code.gitea.io/gitea) + - blank + - default gofumpt: extra-rules: true exclusions: @@ -170,9 +191,6 @@ formatters: - .venv - public - web_src - - third_party$ - - builtin$ - - examples$ run: timeout: 10m diff --git a/AGENTS.md b/AGENTS.md index 589dea7865f..fd87f432b71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,14 @@ - Run `make fmt` to format `.go` files, and run `make lint-go` to lint them - Run `make lint-js` to lint `.ts` files - Run `make tidy` after any `go.mod` changes +- Run single go tests with `go test -tags 'sqlite sqlite_unlock_notify' -run '^TestName$' ./modulepath/` +- Run single js test files with `pnpm exec vitest ` +- Run single playwright e2e test files with `GITEA_TEST_E2E_FLAGS='' make test-e2e` - Add the current year into the copyright header of new `.go` files - Ensure no trailing whitespace in edited files - Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates - Preserve existing code comments, do not remove or rewrite comments that are still relevant - In TypeScript, use `!` (non-null assertion) instead of `?.`/`??` when a value is known to always exist +- For CSS layout, prefer `flex-*` helpers over per-child `tw-ml-*` / `tw-mr-*` margins; fall back to `tw-*` utilities when specificity requires `!important` - Include authorship attribution in issue and pull request comments - Add `Co-Authored-By` lines to all commits, indicating name and model used diff --git a/CHANGELOG.md b/CHANGELOG.md index b662cb4ad5b..c3b6b94269b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,428 @@ 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.26.1](https://github.com/go-gitea/gitea/releases/tag/v1.26.1) - 2026-04-21 + +* BUGFIXES + * Add event.schedule context for schedule actions task (#37320) (#37348) + * Fix an issue where changing an organization's visibility caused problems when users had forked its repositories. (#37324) (#37344) + * Use modern "git update-index --cacheinfo" syntax to support more file names (#37338) (#37343) + * Fix URL related escaping for oauth2 (#37334) (#37340) + * When the requested arch rpm is missing fall back to noarch (#37236) (#37339) + * Fix actions concurrency groups cross-branch leak (#37311) (#37331) + * Fix bug when accessing user badges (#37321) (#37329) + * Fix AppFullLink (#37325) (#37328) + * Fix container auth for public instance (#37290) (#37294) + * Enhance GetActionWorkflow to support fallback references (#37189) (#37283) + * Fix vite manifest update masking build errors (#37279) (#37310) + * Fix Mermaid diagrams failing when node labels contain line breaks (#37296) (#37299) + * Use TriggerEvent instead of Event in workflow runs API response for scheduled runs (#37288) #37360 + * Add URL to Learn more about blocking a user. (#37355) #37367 + * Fix button layout shift when collapsing file tree in editor (#37363) #37375 + * Fix org team assignee/reviewer lookups for team member permissions (#37365) #37391 + * Fix repo init README EOL (#37388) #37399 + * Fix: dump with default zip type produces uncompressed zip (#37401) #37402 + +## [1.26.0](https://github.com/go-gitea/gitea/releases/tag/v1.26.0) - 2026-04-17 + +* BREAKING + * Correct swagger annotations for enums, status codes, and notification state (#37030) + * Remove GET API registration-token (#36801) + * Support Actions `concurrency` syntax (#32751) + * Make PUBLIC_URL_DETECTION default to "auto" (#36955) +* SECURITY + * Bound PageSize in `ListUnadoptedRepositories` (#36884) +* FEATURES + * Support Actions `concurrency` syntax (#32751) + * Add terraform state registry (#36710) + * Instance-wide (global) info banner and maintenance mode (#36571) + * Support rendering OpenAPI spec (#36449) + * Add keyboard shortcuts for repository file and code search (#36416) + * Add support for archive-upload rpc (#36391) + * Add ability to download subpath archive (#36371) + * Add workflow dependencies visualization (#26062) (#36248) & Restyle Workflow Graph (#36912) + * Automatic generation of release notes (#35977) + * Add "Go to file", "Delete Directory" to repo file list page (#35911) + * Introduce "config edit-ini" sub command to help maintaining INI config file (#35735) + * Add button to re-run failed jobs in Actions (#36924) + * Support actions and reusable workflows from private repos (#32562) + * Add summary to action runs view (#36883) + * Add user badges (#36752) + * Add configurable permissions for Actions automatic tokens (#36173) + * Add per-runner "Disable/Pause" (#36776) + * Feature non-zipped actions artifacts (action v7 / nodejs / npm v6.2.0) (#36786) +* PERFORMANCE + * WorkflowDispatch API optionally return runid (#36706) + * Add render cache for SVG icons (#36863) + * Load `mentionValues` asynchronously (#36739) + * Lazy-load some Vue components, fix heatmap chunk loading on every page (#36719) + * Load heatmap data asynchronously (#36622) + * Use prev/next pagination for user profile activities page to speed up (#36642) + * Refactor cat-file batch operations and support `--batch-command` approach (#35775) + * Use merge tree to detect conflicts when possible (#36400) +* ENHANCEMENTS + * Implement logout redirection for reverse proxy auth setups (#36085) (#37171) + * Adds option to force update new branch in contents routes (#35592) + * Add viewer controller for mermaid (zoom, drag) (#36557) + * Add code editor setting dropdowns (#36534) + * Add `elk` layout support to mermaid (#36486) + * Add resolve/unresolve review comment API endpoints (#36441) + * Allow configuring default PR base branch (fixes #36412) (#36425) + * Add support for RPM Errata (updateinfo.xml) (#37125) + * Require additional user confirmation for making repo private (#36959) + * Add `actions.WORKFLOW_DIRS` setting (#36619) + * Avoid opening new tab when downloading actions logs (#36740) + * Implements OIDC RP-Initiated Logout (#36724) + * Show workflow link (#37070) + * Desaturate dark theme background colors (#37056) + * Refactor "org teams" page and help new users to "add member" to an org (#37051) + * Add webhook name field to improve webhook identification (#37025) (#37040) + * Make task list checkboxes clickable in the preview tab (#37010) + * Improve severity labels in Actions logs and tweak colors (#36993) + * Linkify URLs in Actions workflow logs (#36986) + * Allow text selection on checkbox labels (#36970) + * Support dark/light theme images in markdown (#36922) + * Enable native dark mode for swagger-ui (#36899) + * Rework checkbox styling, remove `input` border hover effect (#36870) + * Refactor storage content-type handling of ServeDirectURL (#36804) + * Use "Enable Gravatar" but not "Disable" (#36771) + * Use case-insensitive matching for Git error "Not a valid object name" (#36728) + * Add "Copy Source" to markup comment menu (#36726) + * Change image transparency grid to CSS (#36711) + * Add "Run" prefix for unnamed action steps (#36624) + * Persist actions log time display settings in `localStorage` (#36623) + * Use first commit title for multi-commit PRs and fix auto-focus title field (#36606) + * Improve BuildCaseInsensitiveLike with lowercase (#36598) + * Improve diff highlighting (#36583) + * Exclude cancelled runs from failure-only email notifications (#36569) + * Use full-file highlighting for diff sections (#36561) + * Color command/error logs in Actions log (#36538) + * Add paging headers (#36521) + * Improve timeline entries for WIP prefix changes in pull requests (#36518) + * Add FOLDER_ICON_THEME configuration option (#36496) + * Normalize guessed languages for code highlighting (#36450) + * Add chunked transfer encoding support for LFS uploads (#36380) + * Indicate when only optional checks failed (#36367) + * Add 'allow_maintainer_edit' API option for creating a pull request (#36283) + * Support closing keywords with URL references (#36221) + * Improve diff file headers (#36215) + * Fix and enhance comment editor monospace toggle (#36181) + * Add git.DIFF_RENAME_SIMILARITY_THRESHOLD option (#36164) + * Add matching pair insertion to markdown textarea (#36121) + * Add sorting/filtering to admin user search API endpoint (#36112) + * Allow action user have read permission in public repo like other user (#36095) + * Disable matchBrackets in monaco (#36089) + * Use GitHub-style commit message for squash merge (#35987) + * Make composer registry support tar.gz and tar.bz2 and fix bugs (#35958) + * Add GITEA_PR_INDEX env variable to githooks (#35938) + * Add proper error message if session provider can not be created (#35520) + * Add button to copy file name in PR files (#35509) + * Move `X_FRAME_OPTIONS` setting from `cors` to `security` section (#30256) + * Add placeholder content for empty content page (#37114) + * Add `DEFAULT_DELETE_BRANCH_AFTER_MERGE` setting (#36917) + * Redirect to the only OAuth2 provider when no other login methods and fix various problems (#36901) + * Add admin badge to navbar avatar (#36790) + * Add `never` option to `PUBLIC_URL_DETECTION` configuration (#36785) + * Add background and run count to actions list page (#36707) + * Add icon to buttons "Close with Comment", "Close Pull Request", "Close Issue" (#36654) + * Add support for in_progress event in workflow_run webhook (#36979) + * Report commit status for pull_request_review events (#36589) + * Render merged pull request title as such in dashboard feed (#36479) + * Feature to be able to filter project boards by milestones (#36321) + * Use user id in noreply emails (#36550) + * Enable pagination on GiteaDownloader.getIssueReactions() (#36549) + * Remove striped tables in UI (#36509) + * Improve control char rendering and escape button styling (#37094) + * Support legacy run/job index-based URLs and refactor migration 326 (#37008) + * Add date to "No Contributions" tooltip (#36190) + * Show edit page confirmation dialog on tree view file change (#36130) + * Mention proc-receive in text for dashboard.resync_all_hooks func (#35991) + * Reuse selectable style for wiki (#35990) + * Support blue yellow colorblind theme (#35910) + * Support selecting theme on the footer (#35741) + * Improve online runner check (#35722) + * Add quick approve button on PR page (#35678) + * Enable commenting on expanded lines in PR diffs (#35662) + * Print PR-Title into tooltip for actions (#35579) + * Use explicit, stronger defaults for newly generated repo signing keys for Debian (#36236) + * Improve the compare page (#36261) + * Unify repo names in system notices (#36491) + * Move package settings to package instead of being tied to version (#37026) + * Add Actions API rerun endpoints for runs and jobs (#36768) + * Add branch_count to repository API (#35351) (#36743) + * Add created_by filter to SearchIssues (#36670) + * Allow admins to rename non-local users (#35970) + * Support updating branch via API (#35951) + * Add an option to automatically verify SSH keys from LDAP (#35927) + * Make "update file" API can create a new file when SHA is not set (#35738) + * Update issue.go with labels documentation (labels content, not ids) (#35522) + * Expose content_version for optimistic locking on issue and PR edits (#37035) + * Pass ServeHeaderOptions by value instead of pointer, fine tune httplib tests (#36982) +* BUGFIXES + * Frontend iframe renderer framework: 3D models, OpenAPI (#37233) (#37273) + * Fix CODEOWNERS absolute path matching. (#37244) (#37264) + * Swift registry metadata: preserve more JSON fields and accept empty metadata (#37254) (#37261) + * Fix user ssh key exporting and tests (#37256) (#37258) + * Fix team member avatar size and add tooltip (#37253) + * Fix commit title rendering in action run and blame (#37243) (#37251) + * Fix corrupted JSON caused by goccy library (#37214) (#37220) + * Add test for "fetch redirect", add CSS value validation for external render (#37207) (#37216) + * Fix incorrect concurrency check (#37205) (#37215) + * Fix handle missing base branch in PR commits API (#37193) (#37203) + * Fix encoding for Matrix Webhooks (#37190) (#37201) + * Fix handle fork-only commits in compare API (#37185) (#37199) + * Indicate form field readonly via background, fix RunUser config (#37175, #37180) (#37178) + * Report structurally invalid workflows to users (#37116) (#37164) + * Fix API not persisting pull request unit config when has_pull_requests is not set (#36718) + * Rename CSS variables and improve colorblind themes (#36353) + * Hide `add-matcher` and `remove-matcher` from actions job logs (#36520) + * Prevent navigation keys from triggering actions during IME composition (#36540) + * Fix vertical alignment of `.commit-sign-badge` children (#36570) + * Fix duplicate startup warnings in admin panel (#36641) + * Fix CODEOWNERS review request attribution using comment metadata (#36348) + * Fix HTML tags appearing in wiki table of contents (#36284) + * Fix various bugs (#37096) + * Fix various legacy problems (#37092) + * Fix RPM Registry 404 when package name contains 'package' (#37087) + * Merge some standalone Vite entries into index.js (#37085) + * Fix various problems (#37077) + * Fix issue label deletion with Actions tokens (#37013) + * Hide delete branch or tag buttons in mirror or archived repositories. (#37006) + * Fix org contact email not clearable once set (#36975) + * Fix a bug when forking a repository in an organization (#36950) + * Preserve sort order of exclusive labels from template repo (#36931) + * Make container registry support Apple Container (basic auth) (#36920) + * Fix the wrong push commits in the pull request when force push (#36914) + * Add class "list-header-filters" to the div for projects (#36889) + * Fix dbfs error handling (#36844) + * Fix incorrect viewed files counter if reverted change was viewed (#36819) + * Refactor avatar package, support default avatar fallback (#36788) + * Fix README symlink resolution in subdirectories like .github (#36775) + * Fix CSS stacking context issue in actions log (#36749) + * Add gpg signing for merge rebase and update by rebase (#36701) + * Delete non-exist branch should return 404 (#36694) + * Fix `TestActionsCollaborativeOwner` (#36657) + * Fix multi-arch Docker build SIGILL by splitting frontend stage (#36646) + * Fix linguist-detectable attribute being ignored for configuration files (#36640) + * Fix state desync in ComboMarkdownEditor (#36625) + * Unify DEFAULT_SHOW_FULL_NAME output in templates and dropdown (#36597) + * Pull Request Pusher should be the author of the merge (#36581) + * Fix various version parsing problems (#36553) + * Fix highlight diff result (#36539) + * Fix mirror sync parser and fix mirror messages (#36504) + * Fix bug when list pull request commits (#36485) + * Fix various bugs (#36446) + * Fix issue filter menu layout (#36426) + * Restrict branch naming when new change matches with protection rules (#36405) + * Fix link/origin referrer and login redirect (#36279) + * Generate IDs for HTML headings without id attribute (#36233) + * Use a migration test instead of a wrong test which populated the meta test repositories and fix a migration bug (#36160) + * Fix issue close timeline icon (#36138) + * Fix diff blob excerpt expansion (#35922) + * Fix external render (#35727) + * Fix review request webhook bug (#35339) (#35723) + * Fix shutdown waitgroup panic (#35676) + * Cleanup ActionRun creation (#35624) + * Fix possible bug when migrating issues/pull requests (#33487) + * Various fixes (#36697) + * Apply notify/register mail flags during install load (#37120) + * Repair duration display for bad stopped timestamps (#37121) + * Fix(upgrade.sh): use HTTPS for GPG key import and restore SELinux context after upgrade (#36930) + * Fix various trivial problems (#36921) + * Fix various trivial problems (#36953) + * Fix NuGet package upload error handling (#37074) + * Fix CodeQL code scanning alerts (#36858) + * Refactor issue sidebar and fix various problems (#37045) + * Fix various problems (#37029) + * Fix relative-time RangeError (#37021) + * Fix chroma lexer mapping (#36629) + * Fix typos and grammar in English locale (#36751) + * Fix milestone/project text overflow in issue sidebar (#36741) + * Fix `no-content` message not rendering after comment edit (#36733) + * Fix theme loading in development (#36605) + * Fix workflow run jobs API returning null steps (#36603) + * Fix timeline event layout overflow with long content (#36595) + * Fix minor UI issues in runner edit page (#36590) + * Fix incorrect vendored detections (#36508) + * Fix editorconfig not respected in PR Conversation view (#36492) + * Don't create self-references in merged PRs (#36490) + * Fix potential incorrect runID in run status update (#36437) + * Fix file-tree ui error when adding files to repo without commits (#36312) + * Improve image captcha contrast for dark mode (#36265) + * Fix panic in blame view when a file has only a single commit (#36230) + * Fix spelling error in migrate-storage cmd utility (#36226) + * Fix code highlighting on blame page (#36157) + * Fix nilnil in onedev downloader (#36154) + * Fix actions lint (#36029) + * Fix oauth2 session gob register (#36017) + * Fix Arch repo pacman.conf snippet (#35825) + * Fix a number of `strictNullChecks`-related issues (#35795) + * Fix URLJoin, markup render link reoslving, sign-in/up/linkaccount page common data (#36861) + * Hide delete directory button for mirror or archive repository and disable the menu item if user have no permission (#36384) + * Update message severity colors, fix navbar double border (#37019) + * Inline and lazy-load EasyMDE CSS, fix border colors (#36714) + * Closed milestones with no issues now show as 100% completed (#36220) + * Add test for ExtendCommentTreePathLength migration and fix bugs (#35791) + * Only turn links to current instance into hash links (#36237) + * Fix typos in code comments: doesnt, dont, wont (#36890) +* REFACTOR + * Clean up and improve non-gitea js error filter (#37148) (#37155) + * Always show owner/repo name in compare page dropdowns (#37172) (#37200) + * Remove dead CSS rules (#37173) (#37177) + * Replace Monaco with CodeMirror (#36764) + * Replace CSRF cookie with `CrossOriginProtection` (#36183) + * Replace index with id in actions routes (#36842) + * Remove unnecessary function parameter (#35765) + * Move jobparser from act repository to Gitea (#36699) + * Refactor compare router param parse (#36105) + * Optimize 'refreshAccesses' to perform update without removing then adding (#35702) + * Clean up checkbox cursor styles (#37016) + * Remove undocumented support of signing key in the repository git configuration file (#36143) + * Switch `cmd/` to use constructor functions. (#36962) + * Use `relative-time` to render absolute dates (#36238) + * Some refactors about GetMergeBase (#36186) + * Some small refactors (#36163) + * Use gitRepo as parameter instead of repopath when invoking sign functions (#36162) + * Move blame to gitrepo (#36161) + * Move some functions to gitrepo package to reduce RepoPath reference directly (#36126) + * Use gitrepo's clone and push when possible (#36093) + * Remove mermaid margin workaround (#35732) + * Move some functions to gitrepo package (#35543) + * Move GetDiverging functions to gitrepo (#35524) + * Use global lock instead of status pool for cron lock (#35507) + * Use explicit mux instead of DefaultServeMux (#36276) + * Use gitrepo's push function (#36245) + * Pass request context to generateAdditionalHeadersForIssue (#36274) + * Move assign project when creating pull request to the same database transaction (#36244) + * Move catfile batch to a sub package of git module (#36232) + * Use gitrepo.Repository instead of wikipath (#35398) + * Use experimental go json v2 library (#35392) + * Refactor template render (#36438) + * Refactor GetRepoRawDiffForFile to avoid unnecessary pipe or goroutine (#36434) + * Refactor text utility classes to Tailwind CSS (#36703) + * Refactor git command stdio pipe (#36422) + * Refactor git command context & pipeline (#36406) + * Refactor git command stdio pipe (#36393) + * Remove unused functions (#36672) + * Refactor Actions Token Access (#35688) + * Move commit related functions to gitrepo package (#35600) + * Move archive function to repo_model and gitrepo (#35514) + * Move some functions to gitrepo package (#35503) + * Use git model to detect whether branch exist instead of gitrepo method (#35459) + * Some refactor for repo path (#36251) + * Extract helper functions from SearchIssues (#36158) + * Refactor merge conan and container auth preserve actions taskID (#36560) + * Refactor Nuget Auth to reuse Basic Auth Token Validation (#36558) + * Refactor ActionsTaskID (#36503) + * Refactor auth middleware (#36848) + * Refactor code render and render control chars (#37078) + * Clean up AppURL, remove legacy origin-url webcomponent (#37090) + * Remove `util.URLJoin` and replace all callers with direct path concatenation (#36867) + * Replace legacy tw-flex utility classes with flex-text-block/inline (#36778) + * Mark unused&immature activitypub as "not implemented" (#36789) +* TESTING + * Add e2e tests for server push events (#36879) + * Rework e2e tests (#36634) + * Add e2e reaction test, improve accessibility, enable parallel testing (#37081) + * Increase e2e test timeouts on CI to fix flaky tests (#37053) +* BUILD + * Upgrade go-git to v5.18.0 (#37269) + * Replace rollup-plugin-license with rolldown-license-plugin (#37130) (#37158) + * Bump min go version to 1.26.2 (#37139) (#37143) + * Convert locale files from ini to json format (#35489) + * Bump golangci-lint to 2.7.2, enable modernize stringsbuilder (#36180) + * Port away from `flake-utils` (#35675) + * Remove nolint (#36252) + * Update the Unlicense copy to latest version (#36636) + * Update to go 1.26.0 and golangci-lint 2.9.0 (#36588) + * Replace `google/go-licenses` with custom generation (#36575) + * Update go dependencies (#36548) + * Bump appleboy/git-push-action from 1.0.0 to 1.2.0 (#36306) + * Remove fomantic form module (#36222) + * Bump setup-node to v6, re-enable cache (#36207) + * Bump crowdin/github-action from 1 to 2 (#36204) + * Revert "Bump alpine to 3.23 (#36185)" (#36202) + * Update chroma to v2.21.1 (#36201) + * Bump astral-sh/setup-uv from 6 to 7 (#36198) + * Bump docker/build-push-action from 5 to 6 (#36197) + * Bump aws-actions/configure-aws-credentials from 4 to 5 (#36196) + * Bump dev-hanz-ops/install-gh-cli-action from 0.1.0 to 0.2.1 (#36195) + * Add JSON linting (#36192) + * Enable dependabot for actions (#36191) + * Bump alpine to 3.23 (#36185) + * Update chroma to v2.21.0 (#36171) + * Update JS deps and eslint enhancements (#36147) + * Update JS deps (#36091) + * update golangci-lint to v2.7.0 (#36079) + * Update JS deps, fix deprecations (#36040) + * Update JS deps (#35978) + * Add toolchain directive to go.mod (#35901) + * Move `gitea-vet` to use `go tool` (#35878) + * Update to go 1.25.4 (#35877) + * Enable TypeScript `strictNullChecks` (#35843) + * Enable `vue/require-typed-ref` eslint rule (#35764) + * Update JS dependencies (#35759) + * Move `codeformat` folder to tools (#35758) + * Update dependencies (#35733) + * Bump happy-dom from 20.0.0 to 20.0.2 (#35677) + * Bump setup-go to v6 (#35660) + * Update JS deps, misc tweaks (#35643) + * Bump happy-dom from 19.0.2 to 20.0.0 (#35625) + * Use bundled version of spectral (#35573) + * Update JS and PY deps (#35565) + * Bump github.com/wneessen/go-mail from 0.6.2 to 0.7.1 (#35557) + * Migrate from webpack to vite (#37002) + * Update JS dependencies and misc tweaks (#37064) + * Update to eslint 10 (#36925) + * Optimize Docker build with dependency layer caching (#36864) + * Update JS deps (#36850) + * Update tool dependencies and fix new lint issues (#36702) + * Remove redundant linter rules (#36658) + * Move Fomantic dropdown CSS to custom module (#36530) + * Remove and forbid `@ts-expect-error` (#36513) + * Refactor git command stderr handling (#36402) + * Enable gocheckcompilerdirectives linter (#36156) + * Replace `lint-go-gopls` with additional `govet` linters (#36028) + * Update golangci-lint to v2.6.0 (#35801) + * Misc tool tweaks (#35734) + * Add cache to container build (#35697) + * Upgrade vite (#37126) + * Update `setup-uv` to v8.0.0 (#37101) + * Upgrade `go-git` to v5.17.2 and related dependencies (#37060) + * Raise minimum Node.js version to 22.18.0 (#37058) + * Upgrade `golang.org/x/image` to v0.38.0 (#37054) + * Update minimum go version to 1.26.1, golangci-lint to 2.11.2, fix test style (#36876) + * Enable eslint concurrency (#36878) + * Vendor relative-time-element as local web component (#36853) + * Update material-icon-theme v5.32.0 (#36832) + * Update Go dependencies (#36781) + * Upgrade minimatch (#36760) + * Remove i18n backport tool at the moment because of translation format changed (#36643) + * Update emoji data for Unicode 16 (#36596) + * Update JS dependencies, adjust webpack config, misc fixes (#36431) + * Update material-icon-theme to v5.31.0 (#36427) + * Update JS and PY deps (#36383) + * Bump alpine to 3.23, add platforms to `docker-dryrun` (#36379) + * Update JS deps (#36354) + * Update goldmark to v1.7.16 (#36343) + * Update chroma to v2.22.0 (#36342) +* DOCS + * Update AI Contribution Policy (#37022) + * Update AGENTS.md with additional guidelines (#37018) + * Add missing cron tasks to example ini (#37012) + * Add AI Contribution Policy to CONTRIBUTING.md (#36651) + * Minor punctuation improvement in CONTRIBUTING.md (#36291) + * Add documentation for markdown anchor post-processing (#36443) +* MISC + * Correct spelling (#36783) + * Update Nix flake (#37110) + * Update Nix flake (#37024) + * Add valid github scopes (#36977) + * Update Nix flake (#36943) + * Update Nix flake (#36902) + * Update Nix flake (#36857) + * Update Nix flake (#36787) + ## [1.25.5](https://github.com/go-gitea/gitea/releases/tag/v1.25.5) - 2026-03-10 * SECURITY diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b32631d6a7..3f0c548dcb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,14 @@ # Contribution Guidelines +This document explains how to contribute changes to the Gitea project. Topic-specific guides live in separate files so the essentials are easier to find. + +| Topic | Document | +| :---- | :------- | +| Backend (Go modules, API v1) | [docs/guideline-backend.md](docs/guideline-backend.md) | +| Frontend (npm, UI guidelines) | [docs/guideline-frontend.md](docs/guideline-frontend.md) | +| Maintainers, TOC, labels, merge queue, commit format for mergers | [docs/community-governance.md](docs/community-governance.md) | +| Release cycle, backports, tagging releases | [docs/release-management.md](docs/release-management.md) | +
Table of Contents - [Contribution Guidelines](#contribution-guidelines) @@ -11,10 +20,6 @@ - [Discuss your design before the implementation](#discuss-your-design-before-the-implementation) - [Issue locking](#issue-locking) - [Building Gitea](#building-gitea) - - [Dependencies](#dependencies) - - [Backend](#backend) - - [Frontend](#frontend) - - [Design guideline](#design-guideline) - [Styleguide](#styleguide) - [Copyright](#copyright) - [Testing](#testing) @@ -22,47 +27,19 @@ - [Code review](#code-review) - [Pull request format](#pull-request-format) - [PR title and summary](#pr-title-and-summary) - - [Milestone](#milestone) - - [Labels](#labels) - [Breaking PRs](#breaking-prs) - [What is a breaking PR?](#what-is-a-breaking-pr) - [How to handle breaking PRs?](#how-to-handle-breaking-prs) - [Maintaining open PRs](#maintaining-open-prs) - - [Getting PRs merged](#getting-prs-merged) - - [Final call](#final-call) - - [Commit messages](#commit-messages) - - [PR Co-authors](#pr-co-authors) - - [PRs targeting `main`](#prs-targeting-main) - - [Backport PRs](#backport-prs) + - [Reviewing PRs](#reviewing-prs) + - [For PR authors](#for-pr-authors) - [Documentation](#documentation) - - [API v1](#api-v1) - - [GitHub API compatibility](#github-api-compatibility) - - [Adding/Maintaining API routes](#addingmaintaining-api-routes) - - [When to use what HTTP method](#when-to-use-what-http-method) - - [Requirements for API routes](#requirements-for-api-routes) - - [Backports and Frontports](#backports-and-frontports) - - [What is backported?](#what-is-backported) - - [How to backport?](#how-to-backport) - - [Format of backport PRs](#format-of-backport-prs) - - [Frontports](#frontports) - [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco) - - [Release Cycle](#release-cycle) - - [Maintainers](#maintainers) - - [Technical Oversight Committee (TOC)](#technical-oversight-committee-toc) - - [TOC election process](#toc-election-process) - - [Current TOC members](#current-toc-members) - - [Previous TOC/owners members](#previous-tocowners-members) - - [Governance Compensation](#governance-compensation) - - [TOC \& Working groups](#toc--working-groups) - - [Roadmap](#roadmap) - - [Versions](#versions) - - [Releasing Gitea](#releasing-gitea)
## Introduction -This document explains how to contribute changes to the Gitea project. \ It assumes you have followed the [installation instructions](https://docs.gitea.com/category/installation). \ Sensitive security-related issues should be reported to [security@gitea.io](mailto:security@gitea.io). @@ -131,34 +108,6 @@ If further discussion is needed, we encourage you to open a new issue instead an See the [development setup instructions](https://docs.gitea.com/development/hacking-on-gitea). -## Dependencies - -### Backend - -Go dependencies are managed using [Go Modules](https://go.dev/cmd/go/#hdr-Module_maintenance). \ -You can find more details in the [go mod documentation](https://go.dev/ref/mod) and the [Go Modules Wiki](https://github.com/golang/go/wiki/Modules). - -Pull requests should only modify `go.mod` and `go.sum` where it is related to your change, be it a bugfix or a new feature. \ -Apart from that, these files should only be modified by Pull Requests whose only purpose is to update dependencies. - -The `go.mod`, `go.sum` update needs to be justified as part of the PR description, -and must be verified by the reviewers and/or merger to always reference -an existing upstream commit. - -### Frontend - -For the frontend, we use [npm](https://www.npmjs.com/). - -The same restrictions apply for frontend dependencies as for backend dependencies, with the exceptions that the files for it are `package.json` and `package-lock.json`, and that new versions must always reference an existing version. - -## Design guideline - -Depending on your change, please read the - -- [backend development guideline](https://docs.gitea.com/contributing/guidelines-backend) -- [frontend development guideline](https://docs.gitea.com/contributing/guidelines-frontend) -- [refactoring guideline](https://docs.gitea.com/contributing/guidelines-refactoring) - ## Styleguide You should always run `make fmt` before committing to conform to Gitea's styleguide. @@ -202,7 +151,7 @@ Here's how to run the test suite: | :-------------------------------- | :---------------------------------------------------------- | | ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output | | ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright, for example ``--ui`` | -| ``GITEA_TEST_E2E_TIMEOUT_FACTOR`` | Timeout multiplier (default: 3 on CI, 1 locally) | +| ``GITEA_TEST_E2E_TIMEOUT_FACTOR`` | Timeout multiplier (default: 4 on CI, 1 locally) | ## Translation @@ -216,6 +165,8 @@ The tool `go run build/backport-locale.go` can be used to backport locales from ## Code review +How labels, milestones, and the merge queue work is documented in [docs/community-governance.md](docs/community-governance.md). + ### Pull request format Please try to make your pull request easy to review for us. \ @@ -260,29 +211,6 @@ Fixes/Closes/Resolves #. to your summary. \ Each issue that will be closed must stand on a separate line. -### Milestone - -A PR should only be assigned to a milestone if it will likely be merged into the given version. \ -As a rule of thumb, assume that a PR will stay open for an additional month for every 100 added lines. \ -PRs without a milestone may not be merged. - -### Labels - -Almost all labels used inside Gitea can be classified as one of the following: - -- `modifies/…`: Determines which parts of the codebase are affected. These labels will be set through the CI. -- `topic/…`: Determines the conceptual component of Gitea that is affected, i.e. issues, projects, or authentication. At best, PRs should only target one component but there might be overlap. Must be set manually. -- `type/…`: Determines the type of an issue or PR (feature, refactoring, docs, bug, …). If GitHub supported scoped labels, these labels would be exclusive, so you should set **exactly** one, not more or less (every PR should fall into one of the provided categories, and only one). -- `issue/…` / `pr/…`: Labels that are specific to issues or PRs respectively and that are only necessary in a given context, i.e. `issue/not-a-bug` or `pr/need-2-approvals` - -Every PR should be labeled correctly with every label that applies. - -There are also some labels that will be managed automatically.\ -In particular, these are - -- the amount of pending required approvals -- has all `backport`s or needs a manual backport - ### Breaking PRs #### What is a breaking PR? @@ -311,165 +239,29 @@ Breaking PRs will not be merged as long as not both of these requirements are me ### Maintaining open PRs -The moment you create a non-draft PR or the moment you convert a draft PR to a non-draft PR is the moment code review starts for it. \ -Once that happens, do not rebase or squash your branch anymore as it makes it difficult to review the new changes. \ -Merge the base branch into your branch only when you really need to, i.e. because of conflicting changes in the mean time. \ -This reduces unnecessary CI runs. \ -Don't worry about merge commits messing up your commit history as every PR will be squash merged. \ -This means that all changes are joined into a single new commit whose message is as described below. +Code review starts when you open a non-draft PR or move a draft out of draft state. After that, do not rebase or squash your branch; it makes new changes harder to review. -### Getting PRs merged +Merge the base branch into yours only when you need to, for example because of conflicting changes elsewhere. That limits unnecessary CI runs. -Changes to Gitea must be reviewed before they are accepted — no matter who -makes the change, even if they are an owner or a maintainer. \ -The only exception are critical bugs that prevent Gitea from being compiled or started. \ -Specifically, we require two approvals from maintainers for every PR. \ -Once this criteria has been met, your PR receives the `lgtm/done` label. \ -From this point on, your only responsibility is to fix merge conflicts or respond to/implement requests by maintainers. \ -It is the responsibility of the maintainers from this point to get your PR merged. +Every PR is squash-merged, so merge commits on your branch do not matter for final history. The squash produces a single commit; mergers follow the [commit message format](docs/community-governance.md#commit-messages) in the governance guide. -If a PR has the `lgtm/done` label and there are no open discussions or merge conflicts anymore, any maintainer can add the `reviewed/wait-merge` label. \ -This label means that the PR is part of the merge queue and will be merged as soon as possible. \ -The merge queue will be cleared in the order of the list below: +### Reviewing PRs - +Maintainers are encouraged to review pull requests in areas where they have expertise or particular interest. -Gitea uses it's own tool, the to automate parts of the review process. \ -This tool does the things listed below automatically: +#### For PR authors -- create a backport PR if needed once the initial PR was merged -- remove the PR from the merge queue after the PR merged -- keep the oldest branch in the merge queue up to date with merges +- **Response**: When answering reviewer questions, use real-world cases or examples and avoid speculation. +- **Discussion**: A discussion is always welcome and should be used to clarify the changes and the intent of the PR. +- **Help**: If you need help with the PR or comments are unclear, ask for clarification. -### Final call - -If a PR has been ignored for more than 7 days with no comments or reviews, and the author or any maintainer believes it will not survive a long wait (such as a refactoring PR), they can send "final call" to the TOC by mentioning them in a comment. - -After another 7 days, if there is still zero approval, this is considered a polite refusal, and the PR will be closed to avoid wasting further time. Therefore, the "final call" has a cost, and should be used cautiously. - -However, if there are no objections from maintainers, the PR can be merged with only one approval from the TOC (not the author). - -### Commit messages - -Mergers are able and required to rewrite the PR title and summary (the first comment of a PR) so that it can produce an easily understandable commit message if necessary. \ -The final commit message should no longer contain any uncertainty such as `hopefully, won't happen anymore`. Replace uncertainty with certainty. - -#### PR Co-authors - -A person counts as a PR co-author the moment they (co-)authored a commit that is not simply a `Merge base branch into branch` commit. \ -Mergers are required to remove such "false-positive" co-authors when writing the commit message. \ -The true co-authors must remain in the commit message. - -#### PRs targeting `main` - -The commit message of PRs targeting `main` is always - -```bash -$PR_TITLE ($PR_INDEX) - -$REWRITTEN_PR_SUMMARY -``` - -#### Backport PRs - -The commit message of backport PRs is always - -```bash -$PR_TITLE ($INITIAL_PR_INDEX) ($BACKPORT_PR_INDEX) - -$REWRITTEN_PR_SUMMARY -``` +Guidance for reviewers, the merge queue, and the squash commit message format is in [docs/community-governance.md](docs/community-governance.md). ## Documentation If you add a new feature or change an existing aspect of Gitea, the documentation for that feature must be created or updated in another PR at [https://gitea.com/gitea/docs](https://gitea.com/gitea/docs). **The docs directory on main repository will be removed at some time. We will have a yaml file to store configuration file's meta data. After that completed, configuration documentation should be in the main repository.** -## API v1 - -The API is documented by [swagger](https://gitea.com/api/swagger) and is based on [the GitHub API](https://docs.github.com/en/rest). - -### GitHub API compatibility - -Gitea's API should use the same endpoints and fields as the GitHub API as far as possible, unless there are good reasons to deviate. \ -If Gitea provides functionality that GitHub does not, a new endpoint can be created. \ -If information is provided by Gitea that is not provided by the GitHub API, a new field can be used that doesn't collide with any GitHub fields. \ -Updating an existing API should not remove existing fields unless there is a really good reason to do so. \ -The same applies to status responses. If you notice a problem, feel free to leave a comment in the code for future refactoring to API v2 (which is currently not planned). - -### Adding/Maintaining API routes - -All expected results (errors, success, fail messages) must be documented ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L319-L327)). \ -All JSON input types must be defined as a struct in [modules/structs/](modules/structs/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L76-L91)) \ -and referenced in [routers/api/v1/swagger/options.go](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/options.go). \ -They can then be used like [this example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L318). \ -All JSON responses must be defined as a struct in [modules/structs/](modules/structs/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L36-L68)) \ -and referenced in its category in [routers/api/v1/swagger/](routers/api/v1/swagger/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/issue.go#L11-L16)) \ -They can be used like [this example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L277-L279). - -### When to use what HTTP method - -In general, HTTP methods are chosen as follows: - -- **GET** endpoints return the requested object(s) and status **OK (200)** -- **DELETE** endpoints return the status **No Content (204)** and no content either -- **POST** endpoints are used to **create** new objects (e.g. a User) and return the status **Created (201)** and the created object -- **PUT** endpoints are used to **add/assign** existing Objects (e.g. a user to a team) and return the status **No Content (204)** and no content either -- **PATCH** endpoints are used to **edit/change** an existing object and return the changed object and the status **OK (200)** - -### Requirements for API routes - -All parameters of endpoints changing/editing an object must be optional (except the ones to identify the object, which are required). - -Endpoints returning lists must - -- support pagination (`page` & `limit` options in query) -- set `X-Total-Count` header via **SetTotalCountHeader** ([example](https://github.com/go-gitea/gitea/blob/7aae98cc5d4113f1e9918b7ee7dd09f67c189e3e/routers/api/v1/repo/issue.go#L444)) - -## Backports and Frontports - -### What is backported? - -We backport PRs given the following circumstances: - -1. Feature freeze is active, but `-rc0` has not been released yet. Here, we backport as much as possible. -2. `rc0` has been released. Here, we only backport bug- and security-fixes, and small enhancements. Large PRs such as refactors are not backported anymore. -3. We never backport new features. -4. We never backport breaking changes except when - 1. The breaking change has no effect on the vast majority of users - 2. The component triggering the breaking change is marked as experimental - -### How to backport? - -In the past, it was necessary to manually backport your PRs. \ -Now, that's not a requirement anymore as our [backport bot](https://github.com/GiteaBot) tries to create backports automatically once the PR is merged when the PR - -- does not have the label `backport/manual` -- has the label `backport/` - -The `backport/manual` label signifies either that you want to backport the change yourself, or that there were conflicts when backporting, thus you **must** do it yourself. - -### Format of backport PRs - -The title of backport PRs should be - -``` - (#) -``` - -The first two lines of the summary of the backporting PR should be - -``` -Backport # - -``` - -with the rest of the summary and labels matching the original PR. - -### Frontports - -Frontports behave exactly as described above for backports. - ## Developer Certificate of Origin (DCO) We consider the act of contributing to the code by submitting a Pull Request as the "Sign off" or agreement to the certifications and terms of the [DCO](DCO) and [MIT license](LICENSE). \ @@ -483,148 +275,3 @@ Signed-off-by: Joe Smith If you set the `user.name` and `user.email` Git config options, you can add the line to the end of your commits automatically with `git commit -s`. We assume in good faith that the information you provide is legally binding. - -## Release Cycle - -We adopted a release schedule to streamline the process of working on, finishing, and issuing releases. \ -The overall goal is to make a major release every three or four months, which breaks down into two or three months of general development followed by one month of testing and polishing known as the release freeze. \ -All the feature pull requests should be -merged before feature freeze. All feature pull requests haven't been merged before this feature freeze will be moved to next milestone, please notice our feature freeze announcement on discord. And, during the frozen period, a corresponding -release branch is open for fixes backported from main branch. Release candidates -are made during this period for user testing to -obtain a final version that is maintained in this branch. - -During a development cycle, we may also publish any necessary minor releases -for the previous version. For example, if the latest, published release is -v1.2, then minor changes for the previous release—e.g., v1.1.0 -> v1.1.1—are -still possible. - -## Maintainers - -To make sure every PR is checked, we have [maintainers](MAINTAINERS). \ -Every PR **must** be reviewed by at least two maintainers (or owners) before it can get merged. \ -For refactoring PRs after a week and documentation only PRs, the approval of only one maintainer is enough. \ -A maintainer should be a contributor of Gitea and contributed at least -4 accepted PRs. A contributor should apply as a maintainer in the -[Discord](https://discord.gg/Gitea) `#develop` channel. The team maintainers may invite the contributor. A maintainer -should spend some time on code reviews. If a maintainer has no -time to do that, they should apply to leave the maintainers team -and we will give them the honor of being a member of the [advisors -team](https://github.com/orgs/go-gitea/teams/advisors). Of course, if -an advisor has time to code review, we will gladly welcome them back -to the maintainers team. If a maintainer is inactive for more than 3 -months and forgets to leave the maintainers team, the owners may move -him or her from the maintainers team to the advisors team. -For security reasons, Maintainers should use 2FA for their accounts and -if possible provide GPG signed commits. -https://help.github.com/articles/securing-your-account-with-two-factor-authentication-2fa/ -https://help.github.com/articles/signing-commits-with-gpg/ - -Furthermore, any account with write access (like bots and TOC members) **must** use 2FA. -https://help.github.com/articles/securing-your-account-with-two-factor-authentication-2fa/ - -## Technical Oversight Committee (TOC) - -At the start of 2023, the `Owners` team was dissolved. Instead, the governance charter proposed a technical oversight committee (TOC) which expands the ownership team of the Gitea project from three elected positions to six positions. Three positions are elected as it has been over the past years, and the other three consist of appointed members from the Gitea company. -https://blog.gitea.com/quarterly-23q1/ - -### TOC election process - -Any maintainer is eligible to be part of the community TOC if they are not associated with the Gitea company. -A maintainer can either nominate themselves, or can be nominated by other maintainers to be a candidate for the TOC election. -If you are nominated by someone else, you must first accept your nomination before the vote starts to be a candidate. - -The TOC is elected for one year, the TOC election happens yearly. -After the announcement of the results of the TOC election, elected members have two weeks time to confirm or refuse the seat. -If an elected member does not answer within this timeframe, they are automatically assumed to refuse the seat. -Refusals result in the person with the next highest vote getting the same choice. -As long as seats are empty in the TOC, members of the previous TOC can fill them until an elected member accepts the seat. - -If an elected member that accepts the seat does not have 2FA configured yet, they will be temporarily counted as `answer pending` until they manage to configure 2FA, thus leaving their seat empty for this duration. - -### Current TOC members - -- 2024-01-01 ~ 2024-12-31 - - Company - - [Jason Song](https://gitea.com/wolfogre) - - [Lunny Xiao](https://gitea.com/lunny) - - [Matti Ranta](https://gitea.com/techknowlogick) - - Community - - [6543](https://gitea.com/6543) <6543@obermui.de> - - [delvh](https://gitea.com/delvh) - - [John Olheiser](https://gitea.com/jolheiser) - -### Previous TOC/owners members - -Here's the history of the owners and the time they served: - -- [Lunny Xiao](https://gitea.com/lunny) - 2016, 2017, [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 -- [Kim Carlbäcker](https://github.com/bkcsoft) - 2016, 2017 -- [Thomas Boerger](https://gitea.com/tboerger) - 2016, 2017 -- [Lauris Bukšis-Haberkorns](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801) -- [Matti Ranta](https://gitea.com/techknowlogick) - [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 -- [Andrew Thornton](https://gitea.com/zeripath) - [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 -- [6543](https://gitea.com/6543) - 2023 -- [John Olheiser](https://gitea.com/jolheiser) - 2023 -- [Jason Song](https://gitea.com/wolfogre) - 2023 - -## Governance Compensation - -Each member of the community elected TOC will be granted $500 each month as compensation for their work. - -Furthermore, any community release manager for a specific release or LTS will be compensated $500 for the delivery of said release. - -These funds will come from community sources like the OpenCollective rather than directly from the company. -Only non-company members are eligible for this compensation, and if a member of the community TOC takes the responsibility of release manager, they would only be compensated for their TOC duties. -Gitea Ltd employees are not eligible to receive any funds from the OpenCollective unless it is reimbursement for a purchase made for the Gitea project itself. - -## TOC & Working groups - -With Gitea covering many projects outside of the main repository, several groups will be created to help focus on specific areas instead of requiring maintainers to be a jack-of-all-trades. Maintainers are of course more than welcome to be part of multiple groups should they wish to contribute in multiple places. - -The currently proposed groups are: - -- **Core Group**: maintain the primary Gitea repository -- **Integration Group**: maintain the Gitea ecosystem's related tools, including go-sdk/tea/changelog/bots etc. -- **Documentation Group**: maintain related documents and repositories -- **Translation Group**: coordinate with translators and maintain translations -- **Security Group**: managed by TOC directly, members are decided by TOC, maintains security patches/responsible for security items - -## Roadmap - -Each year a roadmap will be discussed with the entire Gitea maintainers team, and feedback will be solicited from various stakeholders. -TOC members need to review the roadmap every year and work together on the direction of the project. - -When a vote is required for a proposal or other change, the vote of community elected TOC members count slightly more than the vote of company elected TOC members. With this approach, we both avoid ties and ensure that changes align with the mission statement and community opinion. - -You can visit our roadmap on the wiki. - -## Versions - -Gitea has the `main` branch as a tip branch and has version branches -such as `release/v1.19`. `release/v1.19` is a release branch and we will -tag `v1.19.0` for binary download. If `v1.19.0` has bugs, we will accept -pull requests on the `release/v1.19` branch and publish a `v1.19.1` tag, -after bringing the bug fix also to the main branch. - -Since the `main` branch is a tip version, if you wish to use Gitea -in production, please download the latest release tag version. All the -branches will be protected via GitHub, all the PRs to every branch must -be reviewed by two maintainers and must pass the automatic tests. - -## Releasing Gitea - -- Let $vmaj, $vmin and $vpat be Major, Minor and Patch version numbers, $vpat should be rc1, rc2, 0, 1, ...... $vmaj.$vmin will be kept the same as milestones on github or gitea in future. -- Before releasing, confirm all the version's milestone issues or PRs has been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours. -- If this is a big version first you have to create PR for changelog on branch `main` with PRs with label `changelog` and after it has been merged do following steps: - - Create `-dev` tag as `git tag -s -F release.notes v$vmaj.$vmin.0-dev` and push the tag as `git push origin v$vmaj.$vmin.0-dev`. - - When CI has finished building tag then you have to create a new branch named `release/v$vmaj.$vmin` -- If it is bugfix version create PR for changelog on branch `release/v$vmaj.$vmin` and wait till it is reviewed and merged. -- Add a tag as `git tag -s -F release.notes v$vmaj.$vmin.$`, release.notes file could be a temporary file to only include the changelog this version which you added to `CHANGELOG.md`. -- And then push the tag as `git push origin v$vmaj.$vmin.$`. Drone CI will automatically create a release and upload all the compiled binary. (But currently it doesn't add the release notes automatically. Maybe we should fix that.) -- If needed send a frontport PR for the changelog to branch `main` and update the version in `docs/config.yaml` to refer to the new version. -- Send PR to [blog repository](https://gitea.com/gitea/blog) announcing the release. -- Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed: - - bump the version of https://dl.gitea.com/gitea/version.json - - merge the blog post PR - - announce the release in discord `#announcements` diff --git a/Makefile b/Makefile index 8c73dc350d4..ae053a8368e 100644 --- a/Makefile +++ b/Makefile @@ -12,16 +12,15 @@ COMMA := , XGO_VERSION := go-1.25.x -AIR_PACKAGE ?= github.com/air-verse/air@v1 -EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3 -GOFUMPT_PACKAGE ?= mvdan.cc/gofumpt@v0.9.2 -GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 -GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 -MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 -SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.1 +AIR_PACKAGE ?= github.com/air-verse/air@v1 # renovate: datasource=go +EDITORCONFIG_CHECKER_PACKAGE ?= github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@v3 # renovate: datasource=go +GOLANGCI_LINT_PACKAGE ?= github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 # renovate: datasource=go +GXZ_PACKAGE ?= github.com/ulikunitz/xz/cmd/gxz@v0.5.15 # renovate: datasource=go +MISSPELL_PACKAGE ?= github.com/golangci/misspell/cmd/misspell@v0.8.0 # renovate: datasource=go +SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.1 # renovate: datasource=go XGO_PACKAGE ?= src.techknowlogick.com/xgo@latest -GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 -ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.11 +GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1 # renovate: datasource=go +ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.11 # renovate: datasource=go DOCKER_IMAGE ?= gitea/gitea DOCKER_TAG ?= latest @@ -206,7 +205,7 @@ clean: ## delete backend and integration files .PHONY: fmt fmt: ## format the Go and template code - @GOFUMPT_PACKAGE=$(GOFUMPT_PACKAGE) $(GO) run tools/code-batch-process.go gitea-fmt -w '{file-list}' + $(GO) run $(GOLANGCI_LINT_PACKAGE) fmt $(eval TEMPLATES := $(shell find templates -type f -name '*.tmpl')) @# strip whitespace after '{{' or '(' and before '}}' or ')' unless there is only @# whitespace before it @@ -278,10 +277,10 @@ lint-frontend: lint-js lint-css ## lint frontend files lint-frontend-fix: lint-js-fix lint-css-fix ## lint frontend files and fix issues .PHONY: lint-backend -lint-backend: lint-go lint-go-gitea-vet lint-editorconfig ## lint backend files +lint-backend: lint-go lint-editorconfig ## lint backend files .PHONY: lint-backend-fix -lint-backend-fix: lint-go-fix lint-go-gitea-vet lint-editorconfig ## lint backend files and fix issues +lint-backend-fix: lint-go-fix lint-editorconfig ## lint backend files and fix issues .PHONY: lint-js lint-js: node_modules ## lint js and ts files @@ -336,11 +335,6 @@ lint-go-windows: @GOOS= GOARCH= $(GO) install $(GOLANGCI_LINT_PACKAGE) golangci-lint run -.PHONY: lint-go-gitea-vet -lint-go-gitea-vet: ## lint go files with gitea-vet - @echo "Running gitea-vet..." - @$(GO) vet -vettool="$(shell GOOS= GOARCH= go tool -n gitea-vet)" ./... - .PHONY: lint-editorconfig lint-editorconfig: @echo "Running editorconfig check..." @@ -730,7 +724,6 @@ deps-backend: ## install backend dependencies deps-tools: ## install tool dependencies $(GO) install $(AIR_PACKAGE) & \ $(GO) install $(EDITORCONFIG_CHECKER_PACKAGE) & \ - $(GO) install $(GOFUMPT_PACKAGE) & \ $(GO) install $(GOLANGCI_LINT_PACKAGE) & \ $(GO) install $(GXZ_PACKAGE) & \ $(GO) install $(MISSPELL_PACKAGE) & \ diff --git a/build/generate-go-licenses.go b/build/generate-go-licenses.go index b710fdb841f..057e6a6e49a 100644 --- a/build/generate-go-licenses.go +++ b/build/generate-go-licenses.go @@ -19,6 +19,7 @@ import ( // regexp is based on go-license, excluding README and NOTICE // https://github.com/google/go-licenses/blob/master/licenses/find.go +// also defined in vite.config.ts var licenseRe = regexp.MustCompile(`^(?i)((UN)?LICEN(S|C)E|COPYING).*$`) // primaryLicenseRe matches exact primary license filenames without suffixes. diff --git a/cmd/admin_user_change_password_test.go b/cmd/admin_user_change_password_test.go index 902632f3e49..cf497517f74 100644 --- a/cmd/admin_user_change_password_test.go +++ b/cmd/admin_user_change_password_test.go @@ -4,6 +4,7 @@ package cmd import ( + "io" "testing" "code.gitea.io/gitea/models/db" @@ -82,7 +83,9 @@ func TestChangePasswordCommand(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := microcmdUserChangePassword().Run(ctx, tc.args) + cmd := microcmdUserChangePassword() + cmd.Writer, cmd.ErrWriter = io.Discard, io.Discard + err := cmd.Run(ctx, tc.args) require.Error(t, err) require.Contains(t, err.Error(), tc.expectedErr) }) diff --git a/cmd/cert_test.go b/cmd/cert_test.go index 4242d8915b3..c5775e52048 100644 --- a/cmd/cert_test.go +++ b/cmd/cert_test.go @@ -4,6 +4,7 @@ package cmd import ( + "io" "path/filepath" "testing" @@ -107,6 +108,7 @@ func TestCertCommandFailures(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { app := cmdCert() + app.Writer, app.ErrWriter = io.Discard, io.Discard tempDir := t.TempDir() certFile := filepath.Join(tempDir, "cert.pem") diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go deleted file mode 100644 index a36d05c76e0..00000000000 --- a/cmd/cmd_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package cmd - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestDefaultCommand(t *testing.T) { - test := func(t *testing.T, args []string, expectedRetName string, expectedRetValid bool) { - called := false - cmd := &cli.Command{ - DefaultCommand: "test", - Commands: []*cli.Command{ - { - Name: "test", - Action: func(ctx context.Context, command *cli.Command) error { - retName, retValid := isValidDefaultSubCommand(command) - assert.Equal(t, expectedRetName, retName) - assert.Equal(t, expectedRetValid, retValid) - called = true - return nil - }, - }, - }, - } - assert.NoError(t, cmd.Run(t.Context(), args)) - assert.True(t, called) - } - test(t, []string{"./gitea"}, "", true) - test(t, []string{"./gitea", "test"}, "", true) - test(t, []string{"./gitea", "other"}, "other", false) -} diff --git a/cmd/cmdtest/cmd_test.go b/cmd/cmdtest/cmd_test.go new file mode 100644 index 00000000000..4ff854f8abf --- /dev/null +++ b/cmd/cmdtest/cmd_test.go @@ -0,0 +1,237 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Tests here reload the config system multiple times with uncontrollable details. +// So they must be in a separate package, to avoid affecting other tests + +package cmdtest + +import ( + "context" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + "testing" + + "code.gitea.io/gitea/cmd" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/util" + + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func makePathOutput(workPath, customPath, customConf string) string { + return fmt.Sprintf("WorkPath=%s\nCustomPath=%s\nCustomConf=%s", workPath, customPath, customConf) +} + +func newTestApp(testCmd cli.Command) *cli.Command { + app := cmd.NewMainApp(cmd.AppVersion{}) + testCmd.Name = util.IfZero(testCmd.Name, "test-cmd") + cmd.PrepareSubcommandWithGlobalFlags(&testCmd) + app.Commands = append(app.Commands, &testCmd) + app.DefaultCommand = testCmd.Name + return app +} + +type runResult struct { + Stdout string + Stderr string + ExitCode int +} + +func runTestApp(app *cli.Command, args ...string) (runResult, error) { + outBuf := new(strings.Builder) + errBuf := new(strings.Builder) + app.Writer = outBuf + app.ErrWriter = errBuf + exitCode := -1 + defer test.MockVariableValue(&cli.ErrWriter, app.ErrWriter)() + defer test.MockVariableValue(&cli.OsExiter, func(code int) { + if exitCode == -1 { + exitCode = code // save the exit code once and then reset the writer (to simulate the exit) + app.Writer, app.ErrWriter, cli.ErrWriter = io.Discard, io.Discard, io.Discard + } + })() + err := cmd.RunMainApp(app, args...) + return runResult{outBuf.String(), errBuf.String(), exitCode}, err +} + +func TestCliCmd(t *testing.T) { + defaultWorkPath := filepath.FromSlash("/tmp/mocked-work-path") + defaultCustomPath := filepath.Join(defaultWorkPath, "custom") + defaultCustomConf := filepath.Join(defaultCustomPath, "conf/app.ini") + defer setting.MockBuiltinPaths(defaultWorkPath, "", "")() + + cli.CommandHelpTemplate = "(command help template)" + cli.RootCommandHelpTemplate = "(app help template)" + cli.SubcommandHelpTemplate = "(subcommand help template)" + + cases := []struct { + env map[string]string + cmd string + exp string + }{ + // help commands + { + cmd: "./gitea -h", + exp: "DEFAULT CONFIGURATION:", + }, + { + cmd: "./gitea help", + exp: "DEFAULT CONFIGURATION:", + }, + + { + cmd: "./gitea -c /dev/null -h", + exp: "ConfigFile: /dev/null", + }, + + { + cmd: "./gitea -c /dev/null help", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea help -c /dev/null", + exp: "ConfigFile: /dev/null", + }, + + { + cmd: "./gitea -c /dev/null test-cmd -h", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea test-cmd -c /dev/null -h", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea test-cmd -h -c /dev/null", + exp: "ConfigFile: /dev/null", + }, + + { + cmd: "./gitea -c /dev/null test-cmd help", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea test-cmd -c /dev/null help", + exp: "ConfigFile: /dev/null", + }, + { + cmd: "./gitea test-cmd help -c /dev/null", + exp: "ConfigFile: /dev/null", + }, + + // parse paths + { + cmd: "./gitea test-cmd", + exp: makePathOutput(defaultWorkPath, defaultCustomPath, defaultCustomConf), + }, + { + cmd: "./gitea -c /tmp/app.ini test-cmd", + exp: makePathOutput(defaultWorkPath, defaultCustomPath, "/tmp/app.ini"), + }, + { + cmd: "./gitea test-cmd -c /tmp/app.ini", + exp: makePathOutput(defaultWorkPath, defaultCustomPath, "/tmp/app.ini"), + }, + { + env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, + cmd: "./gitea test-cmd", + exp: makePathOutput("/tmp", "/tmp/custom", "/tmp/custom/conf/app.ini"), + }, + { + env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, + cmd: "./gitea test-cmd --work-path /tmp/other", + exp: makePathOutput("/tmp/other", "/tmp/other/custom", "/tmp/other/custom/conf/app.ini"), + }, + { + env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, + cmd: "./gitea test-cmd --config /tmp/app-other.ini", + exp: makePathOutput("/tmp", "/tmp/custom", "/tmp/app-other.ini"), + }, + } + + for _, c := range cases { + t.Run(c.cmd, func(t *testing.T) { + app := newTestApp(cli.Command{ + Action: func(ctx context.Context, cmd *cli.Command) error { + _, _ = fmt.Fprint(cmd.Root().Writer, makePathOutput(setting.AppWorkPath, setting.CustomPath, setting.CustomConf)) + return nil + }, + }) + for k, v := range c.env { + t.Setenv(k, v) + } + args := strings.Split(c.cmd, " ") // for test only, "split" is good enough + r, err := runTestApp(app, args...) + assert.NoError(t, err, c.cmd) + assert.NotEmpty(t, c.exp, c.cmd) + if !assert.Contains(t, r.Stdout, c.exp, c.cmd) { + t.Log("Full output:\n" + r.Stdout) + t.Log("Expected:\n" + c.exp) + } + }) + } +} + +func TestCliCmdError(t *testing.T) { + app := newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return errors.New("normal error") }}) + r, err := runTestApp(app, "./gitea", "test-cmd") + assert.Error(t, err) + assert.Equal(t, 1, r.ExitCode) + assert.Empty(t, r.Stdout) + assert.Equal(t, "Command error: normal error\n", r.Stderr) + + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return cli.Exit("exit error", 2) }}) + r, err = runTestApp(app, "./gitea", "test-cmd") + assert.Error(t, err) + assert.Equal(t, 2, r.ExitCode) + assert.Empty(t, r.Stdout) + assert.Equal(t, "exit error\n", r.Stderr) + + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) + r, err = runTestApp(app, "./gitea", "test-cmd", "--no-such") + assert.Error(t, err) + assert.Equal(t, 1, r.ExitCode) + assert.Empty(t, r.Stdout) + assert.Equal(t, "Incorrect Usage: flag provided but not defined: -no-such\n\n", r.Stderr) + + app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) + r, err = runTestApp(app, "./gitea", "test-cmd") + assert.NoError(t, err) + assert.Equal(t, -1, r.ExitCode) // the cli.OsExiter is not called + assert.Empty(t, r.Stdout) + assert.Empty(t, r.Stderr) +} + +func TestCliCmdBefore(t *testing.T) { + ctxNew := context.WithValue(context.Background(), any("key"), "value") + configValues := map[string]string{} + setting.CustomConf = "/tmp/any.ini" + var actionCtx context.Context + app := newTestApp(cli.Command{ + Before: func(context.Context, *cli.Command) (context.Context, error) { + configValues["before"] = setting.CustomConf + return ctxNew, nil + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + configValues["action"] = setting.CustomConf + actionCtx = ctx + return nil + }, + }) + _, err := runTestApp(app, "./gitea", "--config", "/dev/null", "test-cmd") + assert.NoError(t, err) + assert.Equal(t, ctxNew, actionCtx) + assert.Equal(t, "/tmp/any.ini", configValues["before"], "BeforeFunc must be called before preparing config") + assert.Equal(t, "/dev/null", configValues["action"]) +} diff --git a/cmd/cmd.go b/cmd/helper.go similarity index 98% rename from cmd/cmd.go rename to cmd/helper.go index 25e90a16950..9d70b057015 100644 --- a/cmd/cmd.go +++ b/cmd/helper.go @@ -124,7 +124,7 @@ func PrepareConsoleLoggerLevel(defaultLevel log.Level) func(context.Context, *cl if setting.InstallLock { // During config loading, there might also be logs (for example: deprecation warnings). // It must make sure that console logger is set up before config is loaded. - log.Error("Config is loaded before console logger is setup, it will cause bugs. Please fix it.") + log.Error("Config is loaded before console logger is setup, it will cause bugs. Please fix it. CustomConf=%s", setting.CustomConf) return nil, errors.New("console logger must be setup before config is loaded") } level := defaultLevel diff --git a/cmd/main.go b/cmd/main.go index a6b89a6fada..27d8cba2e90 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -48,7 +48,7 @@ DEFAULT CONFIGURATION: } } -func prepareSubcommandWithGlobalFlags(originCmd *cli.Command) { +func PrepareSubcommandWithGlobalFlags(originCmd *cli.Command) { originBefore := originCmd.Before originCmd.Before = func(ctxOrig context.Context, cmd *cli.Command) (ctx context.Context, err error) { ctx = ctxOrig @@ -145,7 +145,7 @@ func NewMainApp(appVer AppVersion) *cli.Command { app.Before = PrepareConsoleLoggerLevel(log.INFO) for i := range subCmdWithConfig { - prepareSubcommandWithGlobalFlags(subCmdWithConfig[i]) + PrepareSubcommandWithGlobalFlags(subCmdWithConfig[i]) } app.Commands = append(app.Commands, subCmdWithConfig...) app.Commands = append(app.Commands, subCmdStandalone...) diff --git a/cmd/main_test.go b/cmd/main_test.go index b1f6bb3ba9b..f367bf12e42 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -5,17 +5,9 @@ package cmd import ( "context" - "errors" - "fmt" - "io" - "path/filepath" - "strings" "testing" "code.gitea.io/gitea/models/unittest" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/test" - "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/urfave/cli/v3" @@ -25,209 +17,28 @@ func TestMain(m *testing.M) { unittest.MainTest(m) } -func makePathOutput(workPath, customPath, customConf string) string { - return fmt.Sprintf("WorkPath=%s\nCustomPath=%s\nCustomConf=%s", workPath, customPath, customConf) -} - -func newTestApp(testCmd cli.Command) *cli.Command { - app := NewMainApp(AppVersion{}) - testCmd.Name = util.IfZero(testCmd.Name, "test-cmd") - prepareSubcommandWithGlobalFlags(&testCmd) - app.Commands = append(app.Commands, &testCmd) - app.DefaultCommand = testCmd.Name - return app -} - -type runResult struct { - Stdout string - Stderr string - ExitCode int -} - -func runTestApp(app *cli.Command, args ...string) (runResult, error) { - outBuf := new(strings.Builder) - errBuf := new(strings.Builder) - app.Writer = outBuf - app.ErrWriter = errBuf - exitCode := -1 - defer test.MockVariableValue(&cli.ErrWriter, app.ErrWriter)() - defer test.MockVariableValue(&cli.OsExiter, func(code int) { - if exitCode == -1 { - exitCode = code // save the exit code once and then reset the writer (to simulate the exit) - app.Writer, app.ErrWriter, cli.ErrWriter = io.Discard, io.Discard, io.Discard - } - })() - err := RunMainApp(app, args...) - return runResult{outBuf.String(), errBuf.String(), exitCode}, err -} - -func TestCliCmd(t *testing.T) { - defaultWorkPath := filepath.Dir(setting.AppPath) - defaultCustomPath := filepath.Join(defaultWorkPath, "custom") - defaultCustomConf := filepath.Join(defaultCustomPath, "conf/app.ini") - - cli.CommandHelpTemplate = "(command help template)" - cli.RootCommandHelpTemplate = "(app help template)" - cli.SubcommandHelpTemplate = "(subcommand help template)" - - cases := []struct { - env map[string]string - cmd string - exp string - }{ - // help commands - { - cmd: "./gitea -h", - exp: "DEFAULT CONFIGURATION:", - }, - { - cmd: "./gitea help", - exp: "DEFAULT CONFIGURATION:", - }, - - { - cmd: "./gitea -c /dev/null -h", - exp: "ConfigFile: /dev/null", - }, - - { - cmd: "./gitea -c /dev/null help", - exp: "ConfigFile: /dev/null", - }, - { - cmd: "./gitea help -c /dev/null", - exp: "ConfigFile: /dev/null", - }, - - { - cmd: "./gitea -c /dev/null test-cmd -h", - exp: "ConfigFile: /dev/null", - }, - { - cmd: "./gitea test-cmd -c /dev/null -h", - exp: "ConfigFile: /dev/null", - }, - { - cmd: "./gitea test-cmd -h -c /dev/null", - exp: "ConfigFile: /dev/null", - }, - - { - cmd: "./gitea -c /dev/null test-cmd help", - exp: "ConfigFile: /dev/null", - }, - { - cmd: "./gitea test-cmd -c /dev/null help", - exp: "ConfigFile: /dev/null", - }, - { - cmd: "./gitea test-cmd help -c /dev/null", - exp: "ConfigFile: /dev/null", - }, - - // parse paths - { - cmd: "./gitea test-cmd", - exp: makePathOutput(defaultWorkPath, defaultCustomPath, defaultCustomConf), - }, - { - cmd: "./gitea -c /tmp/app.ini test-cmd", - exp: makePathOutput(defaultWorkPath, defaultCustomPath, "/tmp/app.ini"), - }, - { - cmd: "./gitea test-cmd -c /tmp/app.ini", - exp: makePathOutput(defaultWorkPath, defaultCustomPath, "/tmp/app.ini"), - }, - { - env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, - cmd: "./gitea test-cmd", - exp: makePathOutput("/tmp", "/tmp/custom", "/tmp/custom/conf/app.ini"), - }, - { - env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, - cmd: "./gitea test-cmd --work-path /tmp/other", - exp: makePathOutput("/tmp/other", "/tmp/other/custom", "/tmp/other/custom/conf/app.ini"), - }, - { - env: map[string]string{"GITEA_WORK_DIR": "/tmp"}, - cmd: "./gitea test-cmd --config /tmp/app-other.ini", - exp: makePathOutput("/tmp", "/tmp/custom", "/tmp/app-other.ini"), - }, - } - - for _, c := range cases { - t.Run(c.cmd, func(t *testing.T) { - defer test.MockVariableValue(&setting.InstallLock, false)() - app := newTestApp(cli.Command{ - Action: func(ctx context.Context, cmd *cli.Command) error { - _, _ = fmt.Fprint(cmd.Root().Writer, makePathOutput(setting.AppWorkPath, setting.CustomPath, setting.CustomConf)) - return nil +func TestDefaultCommand(t *testing.T) { + test := func(t *testing.T, args []string, expectedRetName string, expectedRetValid bool) { + called := false + cmd := &cli.Command{ + DefaultCommand: "test", + Commands: []*cli.Command{ + { + Name: "test", + Action: func(ctx context.Context, command *cli.Command) error { + retName, retValid := isValidDefaultSubCommand(command) + assert.Equal(t, expectedRetName, retName) + assert.Equal(t, expectedRetValid, retValid) + called = true + return nil + }, }, - }) - for k, v := range c.env { - t.Setenv(k, v) - } - args := strings.Split(c.cmd, " ") // for test only, "split" is good enough - r, err := runTestApp(app, args...) - assert.NoError(t, err, c.cmd) - assert.NotEmpty(t, c.exp, c.cmd) - if !assert.Contains(t, r.Stdout, c.exp, c.cmd) { - t.Log("Full output:\n" + r.Stdout) - t.Log("Expected:\n" + c.exp) - } - }) + }, + } + assert.NoError(t, cmd.Run(t.Context(), args)) + assert.True(t, called) } -} - -func TestCliCmdError(t *testing.T) { - app := newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return errors.New("normal error") }}) - r, err := runTestApp(app, "./gitea", "test-cmd") - assert.Error(t, err) - assert.Equal(t, 1, r.ExitCode) - assert.Empty(t, r.Stdout) - assert.Equal(t, "Command error: normal error\n", r.Stderr) - - app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return cli.Exit("exit error", 2) }}) - r, err = runTestApp(app, "./gitea", "test-cmd") - assert.Error(t, err) - assert.Equal(t, 2, r.ExitCode) - assert.Empty(t, r.Stdout) - assert.Equal(t, "exit error\n", r.Stderr) - - app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) - r, err = runTestApp(app, "./gitea", "test-cmd", "--no-such") - assert.Error(t, err) - assert.Equal(t, 1, r.ExitCode) - assert.Empty(t, r.Stdout) - assert.Equal(t, "Incorrect Usage: flag provided but not defined: -no-such\n\n", r.Stderr) - - app = newTestApp(cli.Command{Action: func(ctx context.Context, cmd *cli.Command) error { return nil }}) - r, err = runTestApp(app, "./gitea", "test-cmd") - assert.NoError(t, err) - assert.Equal(t, -1, r.ExitCode) // the cli.OsExiter is not called - assert.Empty(t, r.Stdout) - assert.Empty(t, r.Stderr) -} - -func TestCliCmdBefore(t *testing.T) { - ctxNew := context.WithValue(context.Background(), any("key"), "value") - configValues := map[string]string{} - setting.CustomConf = "/tmp/any.ini" - var actionCtx context.Context - app := newTestApp(cli.Command{ - Before: func(context.Context, *cli.Command) (context.Context, error) { - configValues["before"] = setting.CustomConf - return ctxNew, nil - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - configValues["action"] = setting.CustomConf - actionCtx = ctx - return nil - }, - }) - _, err := runTestApp(app, "./gitea", "--config", "/dev/null", "test-cmd") - assert.NoError(t, err) - assert.Equal(t, ctxNew, actionCtx) - assert.Equal(t, "/tmp/any.ini", configValues["before"], "BeforeFunc must be called before preparing config") - assert.Equal(t, "/dev/null", configValues["action"]) + test(t, []string{"./gitea"}, "", true) + test(t, []string{"./gitea", "test"}, "", true) + test(t, []string{"./gitea", "other"}, "other", false) } diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 26c512b6f6e..97af5fa5fbd 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -41,10 +41,10 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ;; App name that shows in every page title -APP_NAME = ; Gitea: Git with a cup of tea +;APP_NAME = Gitea: Git with a cup of tea ;; ;; RUN_USER will automatically detect the current user - but you can set it here change it if you run locally -RUN_USER = ; git +;RUN_USER = ;; ;; Application run mode, affects performance and debugging: "dev" or "prod", default is "prod" ;; Mode "dev" makes Gitea easier to develop and debug, values other than "dev" are treated as "prod" which is for production use. @@ -461,6 +461,11 @@ INTERNAL_TOKEN = ;; Name of cookie used to store authentication information. ;COOKIE_REMEMBER_NAME = gitea_incredible ;; +;; URL or path that Gitea should redirect users to *after* performing its own logout. +;; Use this, if needed, when authentication is handled by a reverse proxy or SSO. +;; For example: "/my-sso/logout?return=/my-sso/home" +;REVERSE_PROXY_LOGOUT_REDIRECT = +;; ;; Reverse proxy authentication header name of user name, email, and full name ;REVERSE_PROXY_AUTHENTICATION_USER = X-WEBAUTH-USER ;REVERSE_PROXY_AUTHENTICATION_EMAIL = X-WEBAUTH-EMAIL @@ -520,8 +525,11 @@ INTERNAL_TOKEN = ;; Set to "enforced", to force users to enroll into Two-Factor Authentication, users without 2FA have no access to repositories via API or web. ;TWO_FACTOR_AUTH = ;; -;; The value of the X-Frame-Options HTTP header for HTML responses. Use "unset" to remove the header. +;; The value of the X-Frame-Options HTTP header for all responses. Use "unset" to remove the header. ;X_FRAME_OPTIONS = SAMEORIGIN +;; +;; The value of the X-Content-Type-Options HTTP header for all responses. Use "unset" to remove the header. +;X_CONTENT_TYPE_OPTIONS = nosniff ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -587,6 +595,11 @@ ENABLED = true ;; * https://github.com/git-ecosystem/git-credential-manager ;; * https://gitea.com/gitea/tea ;DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea +;; +;; 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. +;; For example: set "my-scheme, com.example.app" to support "my-scheme://..." and "com.example.app://..." redirect URIs. +;CUSTOM_SCHEMES = ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2963,6 +2976,8 @@ LEVEL = Info ;; Comma-separated list of workflow directories, the first one to exist ;; in a repo is used to find Actions workflow files ;WORKFLOW_DIRS = .gitea/workflows,.github/workflows +;; Maximum number of attempts a single workflow run can have. Default value is 50. +;MAX_RERUN_ATTEMPTS = 50 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docs/community-governance.md b/docs/community-governance.md new file mode 100644 index 00000000000..dbf24813295 --- /dev/null +++ b/docs/community-governance.md @@ -0,0 +1,198 @@ +# Community governance and review process + +This document describes maintainer expectations, project governance, and the detailed pull request review workflow (labels, merge queue, commit message format for mergers). For what contributors should do when opening and updating a PR, see [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Code review + +### Milestone + +A PR should only be assigned to a milestone if it will likely be merged into the given version. \ +PRs without a milestone may not be merged. + +### Labels + +Almost all labels used inside Gitea can be classified as one of the following: + +- `modifies/…`: Determines which parts of the codebase are affected. These labels will be set through the CI. +- `topic/…`: Determines the conceptual component of Gitea that is affected, i.e. issues, projects, or authentication. At best, PRs should only target one component but there might be overlap. Must be set manually. +- `type/…`: Determines the type of an issue or PR (feature, refactoring, docs, bug, …). If GitHub supported scoped labels, these labels would be exclusive, so you should set **exactly** one, not more or less (every PR should fall into one of the provided categories, and only one). +- `issue/…` / `lgtm/…`: Labels that are specific to issues or PRs respectively and that are only necessary in a given context, i.e. `issue/not-a-bug` or `lgtm/need 2` + +Every PR should be labeled correctly with every label that applies. + +There are also some labels that will be managed automatically.\ +In particular, these are + +- the amount of pending required approvals +- has all `backport`s or needs a manual backport + +### Reviewing PRs + +Maintainers are encouraged to review pull requests in areas where they have expertise or particular interest. + +#### For reviewers + +- **Verification**: Verify that the PR accurately reflects the changes, and verify that the tests and documentation are complete and aligned with the implementation. +- **Actionable feedback**: Say what should change and why, and distinguish required changes from optional suggestions. +- **Feedback**: Focus feedback on the issue itself and avoid comments about the contributor's abilities. +- **Request changes**: If you request changes (i.e., block a PR), give a clear rationale and, whenever possible, a concrete path to resolution. +- **Approval**: Only approve a PR when you are fully satisfied with its current state - "rubber-stamp" approvals need to be highlighted as such. + +### Getting PRs merged + +Changes to Gitea must be reviewed before they are accepted, including changes from owners and maintainers. The exception is critical bugs that prevent Gitea from compiling or starting. + +We require two maintainer approvals for every PR. When that is satisfied, your PR gets the `lgtm/done` label. After that, you mainly fix merge conflicts and respond to or implement maintainer requests; maintainers drive getting the PR merged. + +If a PR has `lgtm/done`, no open discussions, and no merge conflicts, any maintainer may add `reviewed/wait-merge`. That puts the PR in the merge queue. PRs are merged from the queue in the order of this list: + + + +Gitea uses its own tool, , to automate parts of the review process. The backporter: + +- Creates a backport PR when needed after the initial PR merges. +- Removes the PR from the merge queue after it merges. +- Keeps the oldest branch in the merge queue up to date with merges. + +### Final call + +If a PR has been ignored for more than 7 days with no comments or reviews, and the author or any maintainer believes it will not survive a long wait (such as a refactoring PR), they can send "final call" to the TOC by mentioning them in a comment. + +After another 7 days, if there is still zero approval, this is considered a polite refusal, and the PR will be closed to avoid wasting further time. Therefore, the "final call" has a cost, and should be used cautiously. + +However, if there are no objections from maintainers, the PR can be merged with only one approval from the TOC (not the author). + +### Commit messages + +Mergers are required to rewrite the PR title and the first comment (the summary) when necessary so the squash commit message is clear. + +The final commit message should not hedge: replace phrases like `hopefully, won't happen anymore` with definite wording. + +#### PR Co-authors + +A person counts as a PR co-author once they (co-)authored a commit that is not simply a `Merge base branch into branch` commit. Mergers must remove such false-positive co-authors when writing the squash message. Every true co-author must remain in the commit message. + +#### PRs targeting `main` + +The commit message of PRs targeting `main` is always + +```bash +$PR_TITLE ($PR_INDEX) + +$REWRITTEN_PR_SUMMARY +``` + +#### Backport PRs + +The commit message of backport PRs is always + +```bash +$PR_TITLE ($INITIAL_PR_INDEX) ($BACKPORT_PR_INDEX) + +$REWRITTEN_PR_SUMMARY +``` + +## Maintainers + +We list [maintainers](../MAINTAINERS) so every PR gets proper review. + +#### Review expectations + +Every PR **must** be reviewed by at least two maintainers (or owners) before merge. **Exception:** after one week, refactoring PRs and documentation-only PRs need only one maintainer approval. + +Maintainers are expected to spend time on code reviews. + +#### Becoming a maintainer + +A maintainer should already be a Gitea contributor with at least four merged PRs. To apply, use the [Discord](https://discord.gg/Gitea) `#develop` channel. Maintainer teams may also invite contributors. + +#### Stepping down, advisors, and inactivity + +If you cannot keep reviewing, apply to leave the maintainers team. You can join the [advisors team](https://github.com/orgs/go-gitea/teams/advisors); advisors who want to review again are welcome back as maintainers. + +If a maintainer is inactive for more than three months and has not left the team, owners may move them to the advisors team. + +#### Account security + +For security, maintainers should enable 2FA and sign commits with GPG when possible: + +- [Two-factor authentication](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication) +- [Signing commits with GPG](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits) + +Any account with write access (including bots and TOC members) **must** use [2FA](https://docs.github.com/en/authentication/securing-your-account-with-two-factor-authentication-2fa/configuring-two-factor-authentication). + +## Technical Oversight Committee (TOC) + +At the start of 2023, the `Owners` team was dissolved. Instead, the governance charter proposed a technical oversight committee (TOC) which expands the ownership team of the Gitea project from three elected positions to six positions. Three positions are elected as it has been over the past years, and the other three consist of appointed members from the Gitea company. +https://blog.gitea.com/quarterly-23q1/ + +### TOC election process + +Any maintainer is eligible to be part of the community TOC if they are not associated with the Gitea company. +A maintainer can either nominate themselves, or can be nominated by other maintainers to be a candidate for the TOC election. +If you are nominated by someone else, you must first accept your nomination before the vote starts to be a candidate. + +The TOC is elected for one year, the TOC election happens yearly. +After the announcement of the results of the TOC election, elected members have two weeks time to confirm or refuse the seat. +If an elected member does not answer within this timeframe, they are automatically assumed to refuse the seat. +Refusals result in the person with the next highest vote getting the same choice. +As long as seats are empty in the TOC, members of the previous TOC can fill them until an elected member accepts the seat. + +If an elected member that accepts the seat does not have 2FA configured yet, they will be temporarily counted as `answer pending` until they manage to configure 2FA, thus leaving their seat empty for this duration. + +### Current TOC members + +- 2024-01-01 ~ 2024-12-31 + - Company + - [Jason Song](https://gitea.com/wolfogre) + - [Lunny Xiao](https://gitea.com/lunny) + - [Matti Ranta](https://gitea.com/techknowlogick) + - Community + - [6543](https://gitea.com/6543) <6543@obermui.de> + - [delvh](https://gitea.com/delvh) + - [John Olheiser](https://gitea.com/jolheiser) + +### Previous TOC/owners members + +Here's the history of the owners and the time they served: + +- [Lunny Xiao](https://gitea.com/lunny) - 2016, 2017, [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 +- [Kim Carlbäcker](https://github.com/bkcsoft) - 2016, 2017 +- [Thomas Boerger](https://gitea.com/tboerger) - 2016, 2017 +- [Lauris Bukšis-Haberkorns](https://gitea.com/lafriks) - [2018](https://github.com/go-gitea/gitea/issues/3255), [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801) +- [Matti Ranta](https://gitea.com/techknowlogick) - [2019](https://github.com/go-gitea/gitea/issues/5572), [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 +- [Andrew Thornton](https://gitea.com/zeripath) - [2020](https://github.com/go-gitea/gitea/issues/9230), [2021](https://github.com/go-gitea/gitea/issues/13801), [2022](https://github.com/go-gitea/gitea/issues/17872), 2023 +- [6543](https://gitea.com/6543) - 2023 +- [John Olheiser](https://gitea.com/jolheiser) - 2023 +- [Jason Song](https://gitea.com/wolfogre) - 2023 + +## Governance Compensation + +Each member of the community elected TOC will be granted $500 each month as compensation for their work. + +Furthermore, any community release manager for a specific release or LTS will be compensated $500 for the delivery of said release. + +These funds will come from community sources like the OpenCollective rather than directly from the company. +Only non-company members are eligible for this compensation, and if a member of the community TOC takes the responsibility of release manager, they would only be compensated for their TOC duties. +Gitea Ltd employees are not eligible to receive any funds from the OpenCollective unless it is reimbursement for a purchase made for the Gitea project itself. + +## TOC & Working groups + +With Gitea covering many projects outside of the main repository, several groups will be created to help focus on specific areas instead of requiring maintainers to be a jack-of-all-trades. Maintainers are of course more than welcome to be part of multiple groups should they wish to contribute in multiple places. + +The currently proposed groups are: + +- **Core Group**: maintain the primary Gitea repository +- **Integration Group**: maintain the Gitea ecosystem's related tools, including go-sdk/tea/changelog/bots etc. +- **Documentation Group**: maintain related documents and repositories +- **Translation Group**: coordinate with translators and maintain translations +- **Security Group**: managed by TOC directly, members are decided by TOC, maintains security patches/responsible for security items + +## Roadmap + +Each year a roadmap will be discussed with the entire Gitea maintainers team, and feedback will be solicited from various stakeholders. +TOC members need to review the roadmap every year and work together on the direction of the project. + +When a vote is required for a proposal or other change, the vote of community elected TOC members count slightly more than the vote of company elected TOC members. With this approach, we both avoid ties and ensure that changes align with the mission statement and community opinion. + +You can visit our roadmap on the wiki. diff --git a/docs/guideline-backend.md b/docs/guideline-backend.md new file mode 100644 index 00000000000..bc3e71113f2 --- /dev/null +++ b/docs/guideline-backend.md @@ -0,0 +1,58 @@ +# Backend development + +This document covers backend-specific contribution expectations. For general contribution workflow, see [CONTRIBUTING.md](../CONTRIBUTING.md). + +For coding style and architecture, see also the [backend development guideline](https://docs.gitea.com/contributing/guidelines-backend) on the documentation site. + +## Dependencies + +Go dependencies are managed using [Go Modules](https://go.dev/cmd/go/#hdr-Module_maintenance). \ +You can find more details in the [go mod documentation](https://go.dev/ref/mod) and the [Go Modules Wiki](https://github.com/golang/go/wiki/Modules). + +Pull requests should only modify `go.mod` and `go.sum` where it is related to your change, be it a bugfix or a new feature. \ +Apart from that, these files should only be modified by Pull Requests whose only purpose is to update dependencies. + +The `go.mod`, `go.sum` update needs to be justified as part of the PR description, +and must be verified by the reviewers and/or merger to always reference +an existing upstream commit. + +## API v1 + +The API is documented by [swagger](https://gitea.com/api/swagger) and is based on [the GitHub API](https://docs.github.com/en/rest). + +### GitHub API compatibility + +Gitea's API should use the same endpoints and fields as the GitHub API as far as possible, unless there are good reasons to deviate. \ +If Gitea provides functionality that GitHub does not, a new endpoint can be created. \ +If information is provided by Gitea that is not provided by the GitHub API, a new field can be used that doesn't collide with any GitHub fields. \ +Updating an existing API should not remove existing fields unless there is a really good reason to do so. \ +The same applies to status responses. If you notice a problem, feel free to leave a comment in the code for future refactoring to API v2 (which is currently not planned). + +### Adding/Maintaining API routes + +All expected results (errors, success, fail messages) must be documented ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L319-L327)). \ +All JSON input types must be defined as a struct in [modules/structs/](modules/structs/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L76-L91)) \ +and referenced in [routers/api/v1/swagger/options.go](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/options.go). \ +They can then be used like [this example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L318). \ +All JSON responses must be defined as a struct in [modules/structs/](modules/structs/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/modules/structs/issue.go#L36-L68)) \ +and referenced in its category in [routers/api/v1/swagger/](routers/api/v1/swagger/) ([example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/swagger/issue.go#L11-L16)) \ +They can be used like [this example](https://github.com/go-gitea/gitea/blob/c620eb5b2d0d874da68ebd734d3864c5224f71f7/routers/api/v1/repo/issue.go#L277-L279). + +### When to use what HTTP method + +In general, HTTP methods are chosen as follows: + +- **GET** endpoints return the requested object(s) and status **OK (200)** +- **DELETE** endpoints return the status **No Content (204)** and no content either +- **POST** endpoints are used to **create** new objects (e.g. a User) and return the status **Created (201)** and the created object +- **PUT** endpoints are used to **add/assign** existing Objects (e.g. a user to a team) and return the status **No Content (204)** and no content either +- **PATCH** endpoints are used to **edit/change** an existing object and return the changed object and the status **OK (200)** + +### Requirements for API routes + +All parameters of endpoints changing/editing an object must be optional (except the ones to identify the object, which are required). + +Endpoints returning lists must + +- support pagination (`page` & `limit` options in query) +- set `X-Total-Count` header via **SetTotalCountHeader** ([example](https://github.com/go-gitea/gitea/blob/7aae98cc5d4113f1e9918b7ee7dd09f67c189e3e/routers/api/v1/repo/issue.go#L444)) diff --git a/docs/guideline-frontend.md b/docs/guideline-frontend.md new file mode 100644 index 00000000000..80ebe821777 --- /dev/null +++ b/docs/guideline-frontend.md @@ -0,0 +1,17 @@ +# Frontend development + +This document covers frontend-specific contribution expectations. For general contribution workflow, see [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Dependencies + +For the frontend, we use [npm](https://www.npmjs.com/). + +The same restrictions apply for frontend dependencies as for [backend dependencies](guideline-backend.md#dependencies), with the exceptions that the files for it are `package.json` and `package-lock.json`, and that new versions must always reference an existing version. + +## Design guideline + +Depending on your change, please read the + +- [backend development guideline](https://docs.gitea.com/contributing/guidelines-backend) +- [frontend development guideline](https://docs.gitea.com/contributing/guidelines-frontend) +- [refactoring guideline](https://docs.gitea.com/contributing/guidelines-refactoring) diff --git a/docs/release-management.md b/docs/release-management.md new file mode 100644 index 00000000000..be8d9e1abf2 --- /dev/null +++ b/docs/release-management.md @@ -0,0 +1,115 @@ +# Release management + +This document describes the release cycle, backports, versioning, and the release manager checklist. For everyday contribution workflow, see [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Backports and Frontports + +### What is backported? + +We backport PRs given the following circumstances: + +1. Feature freeze is active, but `-rc0` has not been released yet. Here, we backport as much as possible. +2. `rc0` has been released. Here, we only backport bug- and security-fixes, and small enhancements. Large PRs such as refactors are not backported anymore. +3. We never backport new features. +4. We never backport breaking changes except when + 1. The breaking change has no effect on the vast majority of users + 2. The component triggering the breaking change is marked as experimental + +### How to backport? + +In the past, it was necessary to manually backport your PRs. \ +Now, that's not a requirement anymore as our [backport bot](https://github.com/GiteaBot) tries to create backports automatically once the PR is merged when the PR + +- does not have the label `backport/manual` +- has the label `backport/` + +The `backport/manual` label signifies either that you want to backport the change yourself, or that there were conflicts when backporting, thus you **must** do it yourself. + +### Format of backport PRs + +The title of backport PRs should be + +``` + (#) +``` + +The first two lines of the summary of the backporting PR should be + +``` +Backport # + +``` + +with the rest of the summary and labels matching the original PR. + +### Frontports + +Frontports behave exactly as described above for backports. + +## Release Cycle + +We use a release schedule so work, stabilization, and releases stay predictable. + +### Cadence + +- Aim for a major release about every three or four months. +- Roughly two or three months of general development, then about one month of testing and polish called the **release freeze**. +- *Starting with v1.26 the release cycle will be more predictable and follow a more regular schedule.* + +### Release schedule + +We will try to publish a new major version every three months: + +- v1.26.0 in April 2026 +- v1.27.0 in June 2026 +- v1.28.0 in September 2026 +- v1.29.0 in December 2026 + +#### How is the release handled? +- The release manager will tag the release candidate (e.g. `v1.26.0-rc0`) and publish it for testing in the **first week of the release month**. +- If there are no major issues, the release manager will check with the other maintainers and then tag the final release (e.g. `v1.26.0`) in the **one or two weeks following the release candidate**. + +### Feature freeze + +- Merge feature PRs before the freeze when you can. +- Feature PRs still open at the freeze move to the next milestone. Watch Discord for the freeze announcement. +- During the freeze, a **release branch** takes fixes backported from `main`. Release candidates ship for testing; the final release for that line is maintained from that branch. + +### Patch releases + +During a cycle we may ship patch releases for an older line. For example, if the latest release is v1.2, we can still publish v1.1.1 after v1.1.0. + +### End of life (EOL) + +We support per standard the last major release. For example, if the latest release is v1.26, we support v1.26 and v1.25, but not v1.24 anymore. We will only publish security fixes for the last major release, so if you are using an older release, please upgrade to a supported release as soon as possible. +Also we always try to support the latest on main branch, so if you are using the latest on main, you should be fine. + +## Versions + +Gitea has the `main` branch as a tip branch and has version branches +such as `release/v1.19`. `release/v1.19` is a release branch and we will +tag `v1.19.0` for binary download. If `v1.19.0` has bugs, we will accept +pull requests on the `release/v1.19` branch and publish a `v1.19.1` tag, +after bringing the bug fix also to the main branch. + +Since the `main` branch is a tip version, if you wish to use Gitea +in production, please download the latest release tag version. All the +branches will be protected via GitHub, all the PRs to every branch must +be reviewed by two maintainers and must pass the automatic tests. + +## Releasing Gitea + +- Let MAJOR, MINOR and PATCH be Major, Minor and Patch version numbers, PATCH should be rc1, rc2, 0, 1, ...... MAJOR.MINOR will be kept the same as milestones on github or gitea in future. +- Before releasing, confirm all the version's milestone issues or PRs has been resolved. Then discuss the release on Discord channel #maintainers and get agreed with almost all the owners and mergers. Or you can declare the version and if nobody is against it in about several hours. +- If this is a big version first you have to create PR for changelog on branch `main` with PRs with label `changelog` and after it has been merged do following steps: + - Create `-dev` tag as `git tag -s -F release.notes vMAJOR.MINOR.0-dev` and push the tag as `git push origin vMAJOR.MINOR.0-dev`. + - When CI has finished building tag then you have to create a new branch named `release/vMAJOR.MINOR` +- If it is bugfix version create PR for changelog on branch `release/vMAJOR.MINOR` and wait till it is reviewed and merged. +- Add a tag as `git tag -s -F release.notes vMAJOR.MINOR.PATCH`, release.notes file could be a temporary file to only include the changelog this version which you added to `CHANGELOG.md`. +- And then push the tag as `git push origin vMAJOR.MINOR.$`. CI will automatically create a release and upload all the compiled binary. (But currently it doesn't add the release notes automatically. Maybe we should fix that.) +- If needed send a frontport PR for the changelog to branch `main` and update the version in `docs/config.yaml` to refer to the new version. +- Send PR to [blog repository](https://gitea.com/gitea/blog) announcing the release. +- Verify all release assets were correctly published through CI on dl.gitea.com and GitHub releases. Once ACKed: + - bump the version of https://dl.gitea.com/gitea/version.json + - merge the blog post PR + - announce the release in discord `#announcements` diff --git a/eslint.config.ts b/eslint.config.ts index 5b7884bdce3..29016ed808b 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -574,8 +574,6 @@ export default defineConfig([ 'no-restricted-properties': [2, ...restrictedProperties], 'no-restricted-imports': [2, {paths: [ {name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true}, - {name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true}, - {name: 'idiomorph/htmx', message: 'Loaded in globals.ts', allowTypeImports: true}, ]}], 'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'], 'no-return-assign': [0], @@ -926,6 +924,7 @@ export default defineConfig([ { ...playwright.configs['flat/recommended'], files: ['tests/e2e/**/*.test.ts'], + languageOptions: {globals: {...globals.nodeBuiltin, ...globals.browser}}, rules: { ...playwright.configs['flat/recommended'].rules, 'playwright/expect-expect': [0], @@ -1022,6 +1021,6 @@ export default defineConfig([ }, { files: ['web_src/**/*'], - languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}}, + languageOptions: {globals: {...globals.browser, ...globals.jquery}}, }, ]); diff --git a/flake.lock b/flake.lock index 25ce7939b35..839eaed572d 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1775036866, - "narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=", + "lastModified": 1776877367, + "narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=", "owner": "nixos", "repo": "nixpkgs", - "rev": "6201e203d09599479a3b3450ed24fa81537ebc4e", + "rev": "0726a0ecb6d4e08f6adced58726b95db924cef57", "type": "github" }, "original": { diff --git a/go.mod b/go.mod index d67eaaec273..d7577bfbf07 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( code.gitea.io/sdk/gitea v0.24.1 codeberg.org/gusted/mcaptcha v0.0.0-20220723083913-4f3072e1d570 connectrpc.com/connect v1.19.1 - gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed + gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a gitea.com/go-chi/cache v0.2.1 gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 gitea.com/go-chi/session v0.0.0-20251124165456-68e0254e989e @@ -27,8 +27,8 @@ require ( github.com/PuerkitoBio/goquery v1.12.0 github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.8.0 github.com/alecthomas/chroma/v2 v2.23.1 - github.com/aws/aws-sdk-go-v2/credentials v1.19.14 - github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.12 + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 + github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.13 github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb github.com/blevesearch/bleve/v2 v2.5.7 github.com/bohde/codel v0.2.0 @@ -37,7 +37,7 @@ require ( github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20251013092601-6327009efd21 github.com/chi-middleware/proxy v1.1.1 github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 - github.com/dlclark/regexp2 v1.11.5 + github.com/dlclark/regexp2 v1.12.0 github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 github.com/dustin/go-humanize v1.0.1 github.com/editorconfig/editorconfig-core-go/v2 v2.6.4 @@ -49,15 +49,14 @@ require ( github.com/gliderlabs/ssh v0.3.8 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 - github.com/go-co-op/gocron/v2 v2.20.0 + github.com/go-co-op/gocron/v2 v2.21.0 github.com/go-enry/go-enry/v2 v2.9.6 github.com/go-git/go-billy/v5 v5.8.0 - github.com/go-git/go-git/v5 v5.17.2 + github.com/go-git/go-git/v5 v5.18.0 github.com/go-ldap/ldap/v3 v3.4.13 github.com/go-redsync/redsync/v4 v4.16.0 github.com/go-sql-driver/mysql v1.9.3 - github.com/go-webauthn/webauthn v0.16.3 - github.com/goccy/go-json v0.10.6 + github.com/go-webauthn/webauthn v0.16.5 github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f github.com/gogs/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -79,7 +78,7 @@ require ( github.com/markbates/goth v1.82.0 github.com/mattn/go-isatty v0.0.21 github.com/mattn/go-sqlite3 v1.14.42 - github.com/meilisearch/meilisearch-go v0.36.1 + github.com/meilisearch/meilisearch-go v0.36.2 github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 github.com/microsoft/go-mssqldb v1.9.6 @@ -111,13 +110,13 @@ require ( github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc gitlab.com/gitlab-org/api/client-go v1.46.0 go.yaml.in/yaml/v4 v4.0.0-rc.3 - golang.org/x/crypto v0.49.0 - golang.org/x/image v0.38.0 - golang.org/x/net v0.52.0 + golang.org/x/crypto v0.50.0 + golang.org/x/image v0.39.0 + golang.org/x/net v0.53.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.43.0 - golang.org/x/text v0.35.0 + golang.org/x/text v0.36.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/ini.v1 v1.67.1 @@ -130,7 +129,6 @@ require ( require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect - code.gitea.io/gitea-vet v0.2.3 // indirect dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect @@ -141,10 +139,10 @@ require ( github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect - github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect + github.com/aws/smithy-go v1.25.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect @@ -195,7 +193,8 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/go-webauthn/x v0.2.2 // indirect + github.com/go-webauthn/x v0.2.3 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -259,7 +258,7 @@ require ( github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect - github.com/tinylib/msgp v1.6.3 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/unknwon/com v1.0.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect @@ -306,5 +305,3 @@ replace github.com/Azure/azure-sdk-for-go/sdk/azcore => github.com/Azure/azure-s replace github.com/Azure/azure-sdk-for-go/sdk/storage/azblob => github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 // v1.6.4+ uses API version unsupported by Azurite in CI replace github.com/microsoft/go-mssqldb => github.com/microsoft/go-mssqldb v1.9.7 // downgraded with Azure SDK - -tool code.gitea.io/gitea-vet diff --git a/go.sum b/go.sum index f8a3c5562c0..0b65e6305ff 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= code.gitea.io/actions-proto-go v0.4.1 h1:l0EYhjsgpUe/1VABo2eK7zcoNX2W44WOnb0MSLrKfls= code.gitea.io/actions-proto-go v0.4.1/go.mod h1:mn7Wkqz6JbnTOHQpot3yDeHx+O5C9EGhMEE+htvHBas= -code.gitea.io/gitea-vet v0.2.3 h1:gdFmm6WOTM65rE8FUBTRzeQZYzXePKSSB1+r574hWwI= -code.gitea.io/gitea-vet v0.2.3/go.mod h1:zcNbT/aJEmivCAhfmkHOlT645KNOf9W2KnkLgFjGGfE= code.gitea.io/sdk/gitea v0.24.1 h1:hpaqcdGcBmfMpV7JSbBJVwE99qo+WqGreJYKrDKEyW8= code.gitea.io/sdk/gitea v0.24.1/go.mod h1:5/77BL3sHneCMEiZaMT9lfTvnnibsYxyO48mceCF3qA= code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= @@ -18,8 +16,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= gitea.com/gitea/act v0.261.10 h1:ndwbtuMXXz1dpYF2iwY1/PkgKNETo4jmPXfinTZt8cs= gitea.com/gitea/act v0.261.10/go.mod h1:oIkqQHvU0lfuIWwcpqa4FmU+t3prA89tgkuHUTsrI2c= -gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed h1:EZZBtilMLSZNWtHHcgq2mt6NSGhJSZBuduAlinMEmso= -gitea.com/go-chi/binding v0.0.0-20240430071103-39a851e106ed/go.mod h1:E3i3cgB04dDx0v3CytCgRTTn9Z/9x891aet3r456RVw= +gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a h1:JHoBrfuTSF9Ke9aNfSYj1XRPBHjKPgCApVprnt2Am0M= +gitea.com/go-chi/binding v0.0.0-20260414111559-654cea7ac60a/go.mod h1:FOsLJIMdpiHzBp3Vby6Wfkdw2ppGscrjgU1IC7E4/zQ= gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g= gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q= gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo= @@ -94,18 +92,18 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= -github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= -github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.12 h1:yv3mfWt/eiDTTry6fkN5hh8wHJfU5ygnw+DJp10C0/c= -github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.12/go.mod h1:voO3LP/dZ4CTERiNWCz3DFLbK/8hbfeC1OJkLW+sang= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= +github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.13 h1:IIW5QmNI9PrnDTBCPa75HcD0g+hoD/a+d388dbIAkEM= +github.com/aws/aws-sdk-go-v2/service/codecommit v1.33.13/go.mod h1:B1FUSufCQp3d8VUzob1EY+AdSo2OuEWNEY6GQff7EHA= +github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= +github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -241,8 +239,8 @@ github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21/go.mod h1:xJvkyD6Y2r github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= -github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= @@ -290,8 +288,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc= -github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= +github.com/go-co-op/gocron/v2 v2.21.0 h1:e1nt9AEFglarRH9/9y9q0V5sblwxlknpHPjttEajrwQ= +github.com/go-co-op/gocron/v2 v2.21.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs= github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= @@ -304,8 +302,8 @@ github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDz github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104= -github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= @@ -327,10 +325,10 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-webauthn/webauthn v0.16.3 h1:RorP0c6VbaKP0i0Jxf/vAf7EFb2lmdLW8GLKITeaN5A= -github.com/go-webauthn/webauthn v0.16.3/go.mod h1:R2xjJxSPat5PYKg5r6cUmqXgbHtbv4GmF6uGkqFMLNI= -github.com/go-webauthn/x v0.2.2 h1:zIiipvMbr48CXi5RG0XdBJR94kd8I5LfzHPb/q+YYmk= -github.com/go-webauthn/x v0.2.2/go.mod h1:IpJ5qyWB9NRhLX3C7gIfjTU7RZLXEP6kzFkoVSE7Fz4= +github.com/go-webauthn/webauthn v0.16.5 h1:x+vADHlaiIjta23kGhtwyCIlB5mayKx6SBlpwQ5NF9A= +github.com/go-webauthn/webauthn v0.16.5/go.mod h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg= +github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA= +github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= @@ -520,8 +518,8 @@ github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebG github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= -github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M= -github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM= +github.com/meilisearch/meilisearch-go v0.36.2 h1:MYaMPCpdLh2aYPt+zK+19mLoA4dfBY3S1L7T0FADCjU= +github.com/meilisearch/meilisearch-go v0.36.2/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM= github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= @@ -697,8 +695,8 @@ github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203/go.mod h1:oqN97ltKN github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= -github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= -github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tstranex/u2f v1.0.0 h1:HhJkSzDDlVSVIVt7pDJwCHQj67k7A5EeBgPmeD+pVsQ= github.com/tstranex/u2f v1.0.0/go.mod h1:eahSLaqAS0zsIEv80+vXT7WanXs7MQQDg3j3wGBSayo= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= @@ -732,7 +730,6 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yohcop/openid-go v1.0.1 h1:DPRd3iPO5F6O5zX2e62XpVAbPT6wV51cuucH0z9g3js= github.com/yohcop/openid-go v1.0.1/go.mod h1:b/AvD03P0KHj4yuihb+VtLD6bYYgsy0zqBzPCRjkCNs= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -785,15 +782,14 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= -golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= -golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -805,7 +801,6 @@ golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -819,13 +814,12 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -880,8 +874,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -892,14 +886,13 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200325010219-a49f79bcc224/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200928182047-19e03678916f/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/models/actions/artifact.go b/models/actions/artifact.go index d61afb2aed4..f0effdeecaf 100644 --- a/models/actions/artifact.go +++ b/models/actions/artifact.go @@ -12,6 +12,7 @@ import ( "time" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -61,7 +62,8 @@ const ( // ActionArtifact is a file that is stored in the artifact storage. type ActionArtifact struct { ID int64 `xorm:"pk autoincr"` - RunID int64 `xorm:"index unique(runid_name_path)"` // The run id of the artifact + RunID int64 `xorm:"index unique(runid_attempt_name_path)"` // The run id of the artifact + RunAttemptID int64 `xorm:"index unique(runid_attempt_name_path) NOT NULL DEFAULT 0"` RunnerID int64 RepoID int64 `xorm:"index"` OwnerID int64 @@ -80,9 +82,9 @@ type ActionArtifact struct { // * "application/pdf", "text/html", etc.: real content type of the artifact ContentEncodingOrType string `xorm:"content_encoding"` - ArtifactPath string `xorm:"index unique(runid_name_path)"` // The path to the artifact when runner uploads it - ArtifactName string `xorm:"index unique(runid_name_path)"` // The name of the artifact when runner uploads it - Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete + ArtifactPath string `xorm:"index unique(runid_attempt_name_path)"` // The path to the artifact when runner uploads it + ArtifactName string `xorm:"index unique(runid_attempt_name_path)"` // The name of the artifact when runner uploads it + Status ArtifactStatus `xorm:"index"` // The status of the artifact, uploading, expired or need-delete CreatedUnix timeutil.TimeStamp `xorm:"created"` UpdatedUnix timeutil.TimeStamp `xorm:"updated index"` ExpiredUnix timeutil.TimeStamp `xorm:"index"` // The time when the artifact will be expired @@ -92,12 +94,13 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa if err := t.LoadJob(ctx); err != nil { return nil, err } - artifact, err := getArtifactByNameAndPath(ctx, t.Job.RunID, artifactName, artifactPath) + artifact, err := getArtifactByNameAndPath(ctx, t.Job.RunID, t.Job.RunAttemptID, artifactName, artifactPath) if errors.Is(err, util.ErrNotExist) { artifact := &ActionArtifact{ ArtifactName: artifactName, ArtifactPath: artifactPath, RunID: t.Job.RunID, + RunAttemptID: t.Job.RunAttemptID, RunnerID: t.RunnerID, RepoID: t.RepoID, OwnerID: t.OwnerID, @@ -122,9 +125,9 @@ func CreateArtifact(ctx context.Context, t *ActionTask, artifactName, artifactPa return artifact, nil } -func getArtifactByNameAndPath(ctx context.Context, runID int64, name, fpath string) (*ActionArtifact, error) { +func getArtifactByNameAndPath(ctx context.Context, runID, runAttemptID int64, name, fpath string) (*ActionArtifact, error) { var art ActionArtifact - has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ?", runID, name, fpath).Get(&art) + has, err := db.GetEngine(ctx).Where("run_id = ? AND run_attempt_id = ? AND artifact_name = ? AND artifact_path = ?", runID, runAttemptID, name, fpath).Get(&art) if err != nil { return nil, err } else if !has { @@ -144,6 +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) ArtifactName string Status int FinalizedArtifactsV4 bool @@ -163,6 +167,9 @@ 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 opts.ArtifactName != "" { cond = cond.And(builder.Eq{"artifact_name": opts.ArtifactName}) } @@ -183,15 +190,17 @@ type ActionArtifactMeta struct { ArtifactName string FileSize int64 Status ArtifactStatus + ExpiredUnix timeutil.TimeStamp } -// ListUploadedArtifactsMeta returns all uploaded artifacts meta of a run -func ListUploadedArtifactsMeta(ctx context.Context, repoID, runID int64) ([]*ActionArtifactMeta, error) { +// ListUploadedArtifactsMetaByRunAttempt returns uploaded artifacts meta scoped to a specific run and attempt. +// Pass runAttemptID=0 to target legacy artifacts (pre-v331) belonging to the run. +func ListUploadedArtifactsMetaByRunAttempt(ctx context.Context, repoID, runID, runAttemptID int64) ([]*ActionArtifactMeta, error) { arts := make([]*ActionArtifactMeta, 0, 10) return arts, db.GetEngine(ctx).Table("action_artifact"). - Where("repo_id=? AND run_id=? AND (status=? OR status=?)", repoID, runID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired). + Where("repo_id=? AND run_id=? AND run_attempt_id=? AND (status=? OR status=?)", repoID, runID, runAttemptID, ArtifactStatusUploadConfirmed, ArtifactStatusExpired). GroupBy("artifact_name"). - Select("artifact_name, sum(file_size) as file_size, max(status) as status"). + Select("artifact_name, sum(file_size) as file_size, max(status) as status, max(expired_unix) as expired_unix"). Find(&arts) } @@ -216,12 +225,29 @@ func SetArtifactExpired(ctx context.Context, artifactID int64) error { return err } -// SetArtifactNeedDelete sets an artifact to need-delete, cron job will delete it -func SetArtifactNeedDelete(ctx context.Context, runID int64, name string) error { - _, err := db.GetEngine(ctx).Where("run_id=? AND artifact_name=? AND status = ?", runID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion}) +// SetArtifactNeedDeleteByID sets an artifact to need-delete by ID, cron job will delete it. +func SetArtifactNeedDeleteByID(ctx context.Context, artifactID int64) error { + _, err := db.GetEngine(ctx).Where("id=? AND status = ?", artifactID, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion}) return err } +// SetArtifactNeedDeleteByRunAttempt sets an artifact to need-delete in a run attempt, cron job will delete it. +// runAttemptID may be 0 for legacy artifacts created before ActionRunAttempt existed. +func SetArtifactNeedDeleteByRunAttempt(ctx context.Context, runID, runAttemptID int64, name string) error { + _, err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=? AND artifact_name=? AND status = ?", runID, runAttemptID, name, ArtifactStatusUploadConfirmed).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusPendingDeletion}) + return err +} + +// GetArtifactsByRunAttemptAndName returns all artifacts with the given name in the specified run attempt. +// This supports both attempt-scoped data and legacy artifacts with run_attempt_id=0. +func GetArtifactsByRunAttemptAndName(ctx context.Context, runID, runAttemptID int64, artifactName string) ([]*ActionArtifact, error) { + arts := make([]*ActionArtifact, 0) + return arts, db.GetEngine(ctx). + Where("run_id = ? AND run_attempt_id = ? AND artifact_name = ?", runID, runAttemptID, artifactName). + OrderBy("id"). + Find(&arts) +} + // SetArtifactDeleted sets an artifact to deleted func SetArtifactDeleted(ctx context.Context, artifactID int64) error { _, err := db.GetEngine(ctx).ID(artifactID).Cols("status").Update(&ActionArtifact{Status: ArtifactStatusDeleted}) diff --git a/models/actions/run.go b/models/actions/run.go index bce356c0e2b..b4f4d001715 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -30,7 +30,7 @@ import ( type ActionRun struct { ID int64 Title string - RepoID int64 `xorm:"unique(repo_index) index(repo_concurrency)"` + RepoID int64 `xorm:"unique(repo_index)"` Repo *repo_model.Repository `xorm:"-"` OwnerID int64 `xorm:"index"` WorkflowID string `xorm:"index"` // the name of workflow file @@ -50,15 +50,20 @@ type ActionRun struct { Status Status `xorm:"index"` Version int `xorm:"version default 0"` // Status could be updated concomitantly, so an optimistic lock is needed RawConcurrency string // raw concurrency - ConcurrencyGroup string `xorm:"index(repo_concurrency) NOT NULL DEFAULT ''"` - ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` - // Started and Stopped is used for recording last run time, if rerun happened, they will be reset to 0 + + // Started and Stopped are identical to the latest attempt after ActionRunAttempt was introduced. + // When a rerun creates a new latest attempt, they are reset until the new attempt starts and stops. Started timeutil.TimeStamp Stopped timeutil.TimeStamp - // PreviousDuration is used for recording previous duration + + // PreviousDuration is kept only for legacy runs created before ActionRunAttempt existed. + // New runs and reruns no longer update this field and use attempt-scoped durations instead. PreviousDuration time.Duration - Created timeutil.TimeStamp `xorm:"created"` - Updated timeutil.TimeStamp `xorm:"updated"` + + LatestAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"` + + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` } func init() { @@ -115,7 +120,7 @@ func (run *ActionRun) RefTooltip() string { } // LoadAttributes load Repo TriggerUser if not loaded -func (run *ActionRun) LoadAttributes(ctx context.Context) error { +func (run *ActionRun) LoadAttributes(ctx context.Context) (err error) { if run == nil { return nil } @@ -129,11 +134,10 @@ func (run *ActionRun) LoadAttributes(ctx context.Context) error { } if run.TriggerUser == nil { - u, err := user_model.GetPossibleUserByID(ctx, run.TriggerUserID) + run.TriggerUserID, run.TriggerUser, err = user_model.GetPossibleUserByID(ctx, run.TriggerUserID) if err != nil { return err } - run.TriggerUser = u } return nil @@ -160,6 +164,31 @@ func (run *ActionRun) Duration() time.Duration { return d } +// GetLatestAttempt returns +// - the latest attempt of the run +// - (nil, false, nil) for legacy runs that have no attempt records +func (run *ActionRun) GetLatestAttempt(ctx context.Context) (*ActionRunAttempt, bool, error) { + if run.LatestAttemptID == 0 { + return nil, false, nil + } + attempt, err := GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID) + if err != nil { + return nil, false, err + } + return attempt, true, nil +} + +func (run *ActionRun) GetEffectiveConcurrency(ctx context.Context) (string, bool, error) { + attempt, has, err := run.GetLatestAttempt(ctx) + if err != nil { + return "", false, err + } + if has { + return attempt.ConcurrencyGroup, attempt.ConcurrencyCancel, nil + } + return "", false, nil +} + func (run *ActionRun) GetPushEventPayload() (*api.PushPayload, error) { if run.Event == webhook_module.HookEventPush { var payload api.PushPayload @@ -406,14 +435,11 @@ func UpdateRun(ctx context.Context, run *ActionRun, cols ...string) error { type ActionRunIndex db.ResourceIndex -func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRun, []*ActionRunJob, error) { - runs, err := db.Find[ActionRun](ctx, &FindRunOptions{ - RepoID: repoID, - ConcurrencyGroup: concurrencyGroup, - Status: status, - }) +// GetConcurrentRunAttemptsAndJobs returns run attempts and jobs in the same concurrency group by statuses. +func GetConcurrentRunAttemptsAndJobs(ctx context.Context, repoID int64, concurrencyGroup string, status []Status) ([]*ActionRunAttempt, []*ActionRunJob, error) { + attempts, err := FindConcurrentRunAttempts(ctx, repoID, concurrencyGroup, status) if err != nil { - return nil, nil, fmt.Errorf("find runs: %w", err) + return nil, nil, fmt.Errorf("find run attempts: %w", err) } jobs, err := db.Find[ActionRunJob](ctx, &FindRunJobOptions{ @@ -425,36 +451,34 @@ func GetConcurrentRunsAndJobs(ctx context.Context, repoID int64, concurrencyGrou return nil, nil, fmt.Errorf("find jobs: %w", err) } - return runs, jobs, nil + return attempts, jobs, nil } -func CancelPreviousJobsByRunConcurrency(ctx context.Context, actionRun *ActionRun) ([]*ActionRunJob, error) { - if actionRun.ConcurrencyGroup == "" { +func CancelPreviousJobsByRunConcurrency(ctx context.Context, attempt *ActionRunAttempt) ([]*ActionRunJob, error) { + if attempt.ConcurrencyGroup == "" { return nil, nil } var jobsToCancel []*ActionRunJob statusFindOption := []Status{StatusWaiting, StatusBlocked} - if actionRun.ConcurrencyCancel { + if attempt.ConcurrencyCancel { statusFindOption = append(statusFindOption, StatusRunning) } - runs, jobs, err := GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, statusFindOption) + attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, attempt.RepoID, attempt.ConcurrencyGroup, statusFindOption) if err != nil { return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) } jobsToCancel = append(jobsToCancel, jobs...) // cancel runs in the same concurrency group - for _, run := range runs { - if run.ID == actionRun.ID { + for _, concurrentAttempt := range attempts { + if concurrentAttempt.RunID == attempt.RunID { continue } - jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ - RunID: run.ID, - }) + jobs, err := GetRunJobsByRunAndAttemptID(ctx, concurrentAttempt.RunID, concurrentAttempt.ID) if err != nil { - return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + return nil, fmt.Errorf("find run %d attempt %d jobs: %w", concurrentAttempt.RunID, concurrentAttempt.ID, err) } jobsToCancel = append(jobsToCancel, jobs...) } diff --git a/models/actions/run_attempt.go b/models/actions/run_attempt.go new file mode 100644 index 00000000000..8ef2ddf00a3 --- /dev/null +++ b/models/actions/run_attempt.go @@ -0,0 +1,144 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + "fmt" + "slices" + "time" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +// ActionRunAttempt represents a single execution attempt of an ActionRun. +type ActionRunAttempt struct { + ID int64 + RepoID int64 `xorm:"index(repo_concurrency_status)"` + RunID int64 `xorm:"UNIQUE(run_attempt)"` + Run *ActionRun `xorm:"-"` + Attempt int64 `xorm:"UNIQUE(run_attempt)"` + + TriggerUserID int64 + TriggerUser *user_model.User `xorm:"-"` + + ConcurrencyGroup string `xorm:"index(repo_concurrency_status) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` + + Status Status `xorm:"index(repo_concurrency_status)"` + Started timeutil.TimeStamp + Stopped timeutil.TimeStamp + + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` +} + +func (*ActionRunAttempt) TableName() string { + return "action_run_attempt" +} + +func init() { + db.RegisterModel(new(ActionRunAttempt)) +} + +func (attempt *ActionRunAttempt) Duration() time.Duration { + return calculateDuration(attempt.Started, attempt.Stopped, attempt.Status, attempt.Updated) +} + +func (attempt *ActionRunAttempt) LoadAttributes(ctx context.Context) (err error) { + if attempt == nil { + return nil + } + + if attempt.Run == nil { + run, err := GetRunByRepoAndID(ctx, attempt.RepoID, attempt.RunID) + if err != nil { + return err + } + if err := run.LoadAttributes(ctx); err != nil { + return err + } + attempt.Run = run + } + + if attempt.TriggerUser == nil { + attempt.TriggerUserID, attempt.TriggerUser, err = user_model.GetPossibleUserByID(ctx, attempt.TriggerUserID) + if err != nil { + return err + } + } + + return nil +} + +func GetRunAttemptByRepoAndID(ctx context.Context, repoID, attemptID int64) (*ActionRunAttempt, error) { + var attempt ActionRunAttempt + has, err := db.GetEngine(ctx).Where("repo_id=? AND id=?", repoID, attemptID).Get(&attempt) + if err != nil { + return nil, err + } else if !has { + return nil, fmt.Errorf("run attempt %d in repo %d: %w", attemptID, repoID, util.ErrNotExist) + } + return &attempt, nil +} + +func GetRunAttemptByRunIDAndAttemptNum(ctx context.Context, runID, attemptNum int64) (*ActionRunAttempt, error) { + var attempt ActionRunAttempt + has, err := db.GetEngine(ctx).Where("run_id=? AND attempt=?", runID, attemptNum).Get(&attempt) + if err != nil { + return nil, err + } else if !has { + return nil, fmt.Errorf("run attempt %d for run %d: %w", attemptNum, runID, util.ErrNotExist) + } + return &attempt, nil +} + +// 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) { + attempts := make([]*ActionRunAttempt, 0) + sess := db.GetEngine(ctx).Where("repo_id=? AND concurrency_group=?", repoID, concurrencyGroup) + if len(statuses) > 0 { + sess = sess.In("status", statuses) + } + return attempts, sess.Find(&attempts) +} + +func UpdateRunAttempt(ctx context.Context, attempt *ActionRunAttempt, cols ...string) error { + if slices.Contains(cols, "status") && attempt.Started.IsZero() && attempt.Status.IsRunning() { + attempt.Started = timeutil.TimeStampNow() + cols = append(cols, "started") + } + + sess := db.GetEngine(ctx).ID(attempt.ID) + if len(cols) > 0 { + sess.Cols(cols...) + } + if _, err := sess.Update(attempt); err != nil { + return err + } + + // Only status/timing changes on an attempt need to update the latest run. + if len(cols) > 0 && !slices.Contains(cols, "status") && !slices.Contains(cols, "started") && !slices.Contains(cols, "stopped") { + return nil + } + + run, err := GetRunByRepoAndID(ctx, attempt.RepoID, attempt.RunID) + if err != nil { + return err + } + if run.LatestAttemptID != attempt.ID { + log.Warn("run %d cannot be updated by an old attempt %d", run.LatestAttemptID, attempt.ID) + return nil + } + + run.Status = attempt.Status + run.Started = attempt.Started + run.Stopped = attempt.Stopped + return UpdateRun(ctx, run, "status", "started", "stopped") +} diff --git a/models/actions/run_attempt_list.go b/models/actions/run_attempt_list.go new file mode 100644 index 00000000000..77a5b8f15c9 --- /dev/null +++ b/models/actions/run_attempt_list.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" +) + +type ActionRunAttemptList []*ActionRunAttempt + +// GetUserIDs returns a slice of user's id +func (attempts ActionRunAttemptList) GetUserIDs() []int64 { + return container.FilterSlice(attempts, func(attempt *ActionRunAttempt) (int64, bool) { + return attempt.TriggerUserID, true + }) +} + +func (attempts ActionRunAttemptList) LoadTriggerUser(ctx context.Context) error { + userIDs := attempts.GetUserIDs() + users := make(map[int64]*user_model.User, len(userIDs)) + if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil { + return err + } + for _, attempt := range attempts { + if attempt.TriggerUserID == user_model.ActionsUserID { + attempt.TriggerUser = user_model.NewActionsUser() + } else { + attempt.TriggerUser = users[attempt.TriggerUserID] + if attempt.TriggerUser == nil { + attempt.TriggerUser = user_model.NewGhostUser() + } + } + } + return nil +} + +// ListRunAttemptsByRunID returns all attempts of a run, ordered by attempt number DESC (newest first). +func ListRunAttemptsByRunID(ctx context.Context, runID int64) (ActionRunAttemptList, error) { + var attempts ActionRunAttemptList + return attempts, db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("attempt DESC").Find(&attempts) +} diff --git a/models/actions/run_job.go b/models/actions/run_job.go index d1e5d1e9380..09213299978 100644 --- a/models/actions/run_job.go +++ b/models/actions/run_job.go @@ -34,7 +34,10 @@ type ActionRunJob struct { CommitSHA string `xorm:"index"` IsForkPullRequest bool Name string `xorm:"VARCHAR(255)"` - Attempt int64 + + // for legacy jobs, this counts how many times the job has run; + // otherwise it matches the Attempt of the ActionRunAttempt identified by job.RunAttemptID + Attempt int64 // WorkflowPayload is act/jobparser.SingleWorkflow for act/jobparser.Parse // it should contain exactly one job with global workflow fields for this model @@ -43,8 +46,11 @@ type ActionRunJob struct { JobID string `xorm:"VARCHAR(255)"` // job id in workflow, not job's id Needs []string `xorm:"JSON TEXT"` RunsOn []string `xorm:"JSON TEXT"` - TaskID int64 // the latest task of the job - Status Status `xorm:"index"` + + TaskID int64 // the task created by this job in its own attempt + SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"` // SourceTaskID points to a historical task when this job reuses an earlier attempt's result. + + Status Status `xorm:"index"` RawConcurrency string // raw concurrency from job YAML's "concurrency" section @@ -61,6 +67,14 @@ type ActionRunJob struct { // It is JSON-encoded repo_model.ActionsTokenPermissions and may be empty if not specified. TokenPermissions *repo_model.ActionsTokenPermissions `xorm:"JSON TEXT"` + // RunAttemptID identifies the ActionRunAttempt this job belongs to. + // A value of 0 indicates a legacy job created before ActionRunAttempt existed. + RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"` + // AttemptJobID is unique within a single attempt. + // For jobs created after ActionRunAttempt was introduced, the same logical job is expected to keep the same AttemptJobID across attempts. + // A value of 0 indicates a legacy job created before ActionRunAttempt existed. + AttemptJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + Started timeutil.TimeStamp Stopped timeutil.TimeStamp Created timeutil.TimeStamp `xorm:"created"` @@ -75,6 +89,13 @@ func (job *ActionRunJob) Duration() time.Duration { return calculateDuration(job.Started, job.Stopped, job.Status, job.Updated) } +func (job *ActionRunJob) EffectiveTaskID() int64 { + if job.TaskID > 0 { + return job.TaskID + } + return job.SourceTaskID +} + func (job *ActionRunJob) LoadRun(ctx context.Context) error { if job.Run == nil { run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID) @@ -152,9 +173,50 @@ func GetRunJobByRunAndID(ctx context.Context, runID, jobID int64) (*ActionRunJob return &job, nil } -func GetRunJobsByRunID(ctx context.Context, runID int64) (ActionJobList, error) { +func GetRunJobByAttemptJobID(ctx context.Context, runID, attemptID, attemptJobID int64) (*ActionRunJob, error) { + var job ActionRunJob + has, err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=? AND attempt_job_id=?", runID, attemptID, attemptJobID).Get(&job) + if err != nil { + return nil, err + } else if !has { + return nil, fmt.Errorf("run job with attempt_job_id %d in run %d attempt %d: %w", attemptJobID, runID, attemptID, util.ErrNotExist) + } + + return &job, nil +} + +// GetLatestAttemptJobsByRepoAndRunID returns the jobs of the latest attempt for a run. +// It prefers the latest attempt when one exists, and falls back to legacy jobs with run_attempt_id=0 for runs created before ActionRunAttempt existed. +func GetLatestAttemptJobsByRepoAndRunID(ctx context.Context, repoID, runID int64) (ActionJobList, error) { + run, err := GetRunByRepoAndID(ctx, repoID, runID) + if err != nil { + return nil, err + } + if run.LatestAttemptID > 0 { + return GetRunJobsByRunAndAttemptID(ctx, runID, run.LatestAttemptID) + } + var jobs []*ActionRunJob - if err := db.GetEngine(ctx).Where("run_id=?", runID).OrderBy("id").Find(&jobs); err != nil { + if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=? AND run_attempt_id=0", repoID, runID).OrderBy("id").Find(&jobs); err != nil { + return nil, err + } + return jobs, nil +} + +// GetAllRunJobsByRepoAndRunID returns all jobs for a run across all attempts. +func GetAllRunJobsByRepoAndRunID(ctx context.Context, repoID, runID int64) (ActionJobList, error) { + var jobs []*ActionRunJob + if err := db.GetEngine(ctx).Where("repo_id=? AND run_id=?", repoID, runID).OrderBy("id").Find(&jobs); err != nil { + return nil, err + } + return jobs, nil +} + +// GetRunJobsByRunAndAttemptID returns jobs for a run within a specific attempt. +// runAttemptID may be 0 to address legacy jobs that were created before ActionRunAttempt existed and therefore have no attempt association. +func GetRunJobsByRunAndAttemptID(ctx context.Context, runID, runAttemptID int64) (ActionJobList, error) { + var jobs []*ActionRunJob + if err := db.GetEngine(ctx).Where("run_id=? AND run_attempt_id=?", runID, runAttemptID).OrderBy("id").Find(&jobs); err != nil { return nil, err } return jobs, nil @@ -196,25 +258,51 @@ func UpdateRunJob(ctx context.Context, job *ActionRunJob, cond builder.Cond, col } { - // Other goroutines may aggregate the status of the run and update it too. - // So we need load the run and its jobs before updating the run. - run, err := GetRunByRepoAndID(ctx, job.RepoID, job.RunID) - if err != nil { - return 0, err - } - jobs, err := GetRunJobsByRunID(ctx, 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) + // 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) + } } } @@ -269,7 +357,7 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) if job.ConcurrencyCancel { statusFindOption = append(statusFindOption, StatusRunning) } - runs, jobs, err := GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption) + attempts, jobs, err := GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, statusFindOption) if err != nil { return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) } @@ -277,12 +365,13 @@ func CancelPreviousJobsByJobConcurrency(ctx context.Context, job *ActionRunJob) jobsToCancel = append(jobsToCancel, jobs...) // cancel runs in the same concurrency group - for _, run := range runs { - jobs, err := db.Find[ActionRunJob](ctx, FindRunJobOptions{ - RunID: run.ID, - }) + for _, attempt := range attempts { + if attempt.ID == job.RunAttemptID { + continue + } + jobs, err := GetRunJobsByRunAndAttemptID(ctx, attempt.RunID, attempt.ID) if err != nil { - return nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + return nil, fmt.Errorf("find run %d attempt %d jobs: %w", attempt.RunID, attempt.ID, err) } jobsToCancel = append(jobsToCancel, jobs...) } diff --git a/models/actions/run_job_list.go b/models/actions/run_job_list.go index 10f76d3641b..e06b6beb9ec 100644 --- a/models/actions/run_job_list.go +++ b/models/actions/run_job_list.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/timeutil" "xorm.io/builder" @@ -70,6 +71,7 @@ func (jobs ActionJobList) LoadAttributes(ctx context.Context, withRepo bool) err type FindRunJobOptions struct { db.ListOptions RunID int64 + RunAttemptID optional.Option[int64] // use optional to allow filtering by zero (legacy jobs have run_attempt_id=0) RepoID int64 OwnerID int64 CommitSHA string @@ -83,6 +85,9 @@ func (opts FindRunJobOptions) ToConds() builder.Cond { if opts.RunID > 0 { cond = cond.And(builder.Eq{"`action_run_job`.run_id": opts.RunID}) } + if opts.RunAttemptID.Has() { + cond = cond.And(builder.Eq{"`action_run_job`.run_attempt_id": opts.RunAttemptID.Value()}) + } if opts.RepoID > 0 { cond = cond.And(builder.Eq{"`action_run_job`.repo_id": opts.RepoID}) } diff --git a/models/actions/run_list.go b/models/actions/run_list.go index 2628c4712f5..82dc97f3e5a 100644 --- a/models/actions/run_list.go +++ b/models/actions/run_list.go @@ -7,7 +7,6 @@ import ( "context" "code.gitea.io/gitea/models/db" - repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/translation" @@ -25,12 +24,6 @@ func (runs RunList) GetUserIDs() []int64 { }) } -func (runs RunList) GetRepoIDs() []int64 { - return container.FilterSlice(runs, func(run *ActionRun) (int64, bool) { - return run.RepoID, true - }) -} - func (runs RunList) LoadTriggerUser(ctx context.Context) error { userIDs := runs.GetUserIDs() users := make(map[int64]*user_model.User, len(userIDs)) @@ -50,18 +43,6 @@ func (runs RunList) LoadTriggerUser(ctx context.Context) error { return nil } -func (runs RunList) LoadRepos(ctx context.Context) error { - repoIDs := runs.GetRepoIDs() - repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) - if err != nil { - return err - } - for _, run := range runs { - run.Repo = repos[run.RepoID] - } - return nil -} - type FindRunOptions struct { db.ListOptions RepoID int64 @@ -102,12 +83,6 @@ func (opts FindRunOptions) ToConds() builder.Cond { if opts.CommitSHA != "" { cond = cond.And(builder.Eq{"`action_run`.commit_sha": opts.CommitSHA}) } - if len(opts.ConcurrencyGroup) > 0 { - if opts.RepoID == 0 { - panic("Invalid FindRunOptions: repo_id is required") - } - cond = cond.And(builder.Eq{"`action_run`.concurrency_group": opts.ConcurrencyGroup}) - } return cond } diff --git a/models/actions/run_test.go b/models/actions/run_test.go index e1c884518fa..e82cbe84b51 100644 --- a/models/actions/run_test.go +++ b/models/actions/run_test.go @@ -32,7 +32,7 @@ func TestUpdateRepoRunsNumbers(t *testing.T) { err = UpdateRepoRunsNumbers(t.Context(), repo) assert.NoError(t, err) repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) - assert.Equal(t, 5, repo.NumActionRuns) + assert.Equal(t, 4, repo.NumActionRuns) assert.Equal(t, 3, repo.NumClosedActionRuns) } diff --git a/models/actions/runner.go b/models/actions/runner.go index f5d40ca7d66..f0088491bb5 100644 --- a/models/actions/runner.go +++ b/models/actions/runner.go @@ -171,9 +171,8 @@ func (r *ActionRunner) LoadAttributes(ctx context.Context) error { return nil } -func (r *ActionRunner) GenerateToken() (err error) { - r.Token, r.TokenSalt, r.TokenHash, _, err = generateSaltedToken() - return err +func (r *ActionRunner) GenerateAndFillToken() { + r.Token, r.TokenSalt, r.TokenHash, _ = generateSaltedToken() } // CanMatchLabels checks whether the runner's labels can match a job's "runs-on" diff --git a/models/actions/runner_token.go b/models/actions/runner_token.go index bbd2af73b65..f7b7c9fdf0b 100644 --- a/models/actions/runner_token.go +++ b/models/actions/runner_token.go @@ -97,10 +97,7 @@ func NewRunnerTokenWithValue(ctx context.Context, ownerID, repoID int64, token s } func NewRunnerToken(ctx context.Context, ownerID, repoID int64) (*ActionRunnerToken, error) { - token, err := util.CryptoRandomString(40) - if err != nil { - return nil, err - } + token := util.CryptoRandomString(40) return NewRunnerTokenWithValue(ctx, ownerID, repoID, token) } diff --git a/models/actions/schedule_list.go b/models/actions/schedule_list.go index 5361b94801a..6b5cae94fe9 100644 --- a/models/actions/schedule_list.go +++ b/models/actions/schedule_list.go @@ -4,62 +4,13 @@ package actions import ( - "context" - "code.gitea.io/gitea/models/db" - repo_model "code.gitea.io/gitea/models/repo" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/container" "xorm.io/builder" ) type ScheduleList []*ActionSchedule -// GetUserIDs returns a slice of user's id -func (schedules ScheduleList) GetUserIDs() []int64 { - return container.FilterSlice(schedules, func(schedule *ActionSchedule) (int64, bool) { - return schedule.TriggerUserID, true - }) -} - -func (schedules ScheduleList) GetRepoIDs() []int64 { - return container.FilterSlice(schedules, func(schedule *ActionSchedule) (int64, bool) { - return schedule.RepoID, true - }) -} - -func (schedules ScheduleList) LoadTriggerUser(ctx context.Context) error { - userIDs := schedules.GetUserIDs() - users := make(map[int64]*user_model.User, len(userIDs)) - if err := db.GetEngine(ctx).In("id", userIDs).Find(&users); err != nil { - return err - } - for _, schedule := range schedules { - if schedule.TriggerUserID == user_model.ActionsUserID { - schedule.TriggerUser = user_model.NewActionsUser() - } else { - schedule.TriggerUser = users[schedule.TriggerUserID] - if schedule.TriggerUser == nil { - schedule.TriggerUser = user_model.NewGhostUser() - } - } - } - return nil -} - -func (schedules ScheduleList) LoadRepos(ctx context.Context) error { - repoIDs := schedules.GetRepoIDs() - repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs) - if err != nil { - return err - } - for _, schedule := range schedules { - schedule.Repo = repos[schedule.RepoID] - } - return nil -} - type FindScheduleOptions struct { db.ListOptions RepoID int64 diff --git a/models/actions/task.go b/models/actions/task.go index 77139ddceae..016f91a7bb3 100644 --- a/models/actions/task.go +++ b/models/actions/task.go @@ -147,9 +147,8 @@ func (task *ActionTask) LoadAttributes(ctx context.Context) error { return nil } -func (task *ActionTask) GenerateToken() (err error) { - task.Token, task.TokenSalt, task.TokenHash, task.TokenLastEight, err = generateSaltedToken() - return err +func (task *ActionTask) GenerateAndFillToken() { + task.Token, task.TokenSalt, task.TokenHash, task.TokenLastEight = generateSaltedToken() } func GetTaskByID(ctx context.Context, id int64) (*ActionTask, error) { @@ -273,7 +272,6 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask } now := timeutil.TimeStampNow() - job.Attempt++ job.Started = now job.Status = StatusRunning @@ -288,9 +286,7 @@ func CreateTaskForRunner(ctx context.Context, runner *ActionRunner) (*ActionTask CommitSHA: job.CommitSHA, IsForkPullRequest: job.IsForkPullRequest, } - if err := task.GenerateToken(); err != nil { - return nil, false, err - } + task.GenerateAndFillToken() workflowJob, err := job.ParseJob() if err != nil { diff --git a/models/actions/utils.go b/models/actions/utils.go index 1101a36cfcc..e5704d0377d 100644 --- a/models/actions/utils.go +++ b/models/actions/utils.go @@ -18,18 +18,12 @@ import ( "code.gitea.io/gitea/modules/util" ) -func generateSaltedToken() (string, string, string, string, error) { - salt, err := util.CryptoRandomString(10) - if err != nil { - return "", "", "", "", err - } - buf, err := util.CryptoRandomBytes(20) - if err != nil { - return "", "", "", "", err - } +func generateSaltedToken() (string, string, string, string) { + salt := util.CryptoRandomString(10) + buf := util.CryptoRandomBytes(20) token := hex.EncodeToString(buf) hash := auth_model.HashToken(token, salt) - return token, salt, hash, token[len(token)-8:], nil + return token, salt, hash, token[len(token)-8:] } /* diff --git a/models/activities/action.go b/models/activities/action.go index 8e589eda88d..4ffdca842a4 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -186,15 +186,7 @@ func (a *Action) LoadActUser(ctx context.Context) { if a.ActUser != nil { return } - var err error - a.ActUser, err = user_model.GetPossibleUserByID(ctx, a.ActUserID) - if err == nil { - return - } else if user_model.IsErrUserNotExist(err) { - a.ActUser = user_model.NewGhostUser() - } else { - log.Error("GetUserByID(%d): %v", a.ActUserID, err) - } + a.ActUserID, a.ActUser, _ = user_model.GetPossibleUserByID(ctx, a.ActUserID) } func (a *Action) LoadRepo(ctx context.Context) error { diff --git a/models/activities/action_list.go b/models/activities/action_list.go index 29ff2fdf7a2..5b07a8e080a 100644 --- a/models/activities/action_list.go +++ b/models/activities/action_list.go @@ -282,9 +282,3 @@ func GetFeeds(ctx context.Context, opts GetFeedsOptions) (ActionList, int64, err return actions, count, nil } - -func CountUserFeeds(ctx context.Context, userID int64) (int64, error) { - return db.GetEngine(ctx).Where("user_id = ?", userID). - And("is_deleted = ?", false). - Count(&Action{}) -} diff --git a/models/activities/user_heatmap.go b/models/activities/user_heatmap.go index e24d44c5195..2d1635917ef 100644 --- a/models/activities/user_heatmap.go +++ b/models/activities/user_heatmap.go @@ -62,6 +62,7 @@ func getUserHeatmapData(ctx context.Context, user *user_model.User, team *organi return nil, err } + // HINT: USER-ACTIVITY-PUSH-COMMITS: it only uses the doer's action time, it doesn't use git commit's time return hdata, db.GetEngine(ctx). Select(groupBy+" AS timestamp, count(user_id) as contributions"). Table("action"). diff --git a/models/asymkey/error.go b/models/asymkey/error.go index b7656245791..5df7beb8cd7 100644 --- a/models/asymkey/error.go +++ b/models/asymkey/error.go @@ -192,28 +192,6 @@ func (err ErrGPGKeyIDAlreadyUsed) Unwrap() error { return util.ErrAlreadyExist } -// ErrGPGKeyAccessDenied represents a "GPGKeyAccessDenied" kind of Error. -type ErrGPGKeyAccessDenied struct { - UserID int64 - KeyID int64 -} - -// IsErrGPGKeyAccessDenied checks if an error is a ErrGPGKeyAccessDenied. -func IsErrGPGKeyAccessDenied(err error) bool { - _, ok := err.(ErrGPGKeyAccessDenied) - return ok -} - -// Error pretty-prints an error of type ErrGPGKeyAccessDenied. -func (err ErrGPGKeyAccessDenied) Error() string { - return fmt.Sprintf("user does not have access to the key [user_id: %d, key_id: %d]", - err.UserID, err.KeyID) -} - -func (err ErrGPGKeyAccessDenied) Unwrap() error { - return util.ErrPermissionDenied -} - // ErrKeyAccessDenied represents a "KeyAccessDenied" kind of error. type ErrKeyAccessDenied struct { UserID int64 diff --git a/models/asymkey/ssh_key.go b/models/asymkey/ssh_key.go index 98784b36bd3..1873c30859d 100644 --- a/models/asymkey/ssh_key.go +++ b/models/asymkey/ssh_key.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/models/perm" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -64,7 +65,12 @@ func (key *PublicKey) AfterLoad() { // OmitEmail returns content of public key without email address. func (key *PublicKey) OmitEmail() string { - return strings.Join(strings.Split(key.Content, " ")[:2], " ") + fields := strings.Split(key.Content, " ") // format: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... comment + if len(fields) < 2 { + setting.PanicInDevOrTesting("invalid public key %d content: %s", key.ID, key.Content) + return "" // not a valid public key, it shouldn't really happen, the value is managed internally + } + return strings.Join(fields[:2], " ") } func addKey(ctx context.Context, key *PublicKey) (err error) { diff --git a/models/asymkey/ssh_key_deploy.go b/models/asymkey/ssh_key_deploy.go index 4ab84eabcf6..ea3d93e8c81 100644 --- a/models/asymkey/ssh_key_deploy.go +++ b/models/asymkey/ssh_key_deploy.go @@ -105,14 +105,6 @@ func addDeployKey(ctx context.Context, keyID, repoID int64, name, fingerprint st return key, db.Insert(ctx, key) } -// HasDeployKey returns true if public key is a deploy key of given repository. -func HasDeployKey(ctx context.Context, keyID, repoID int64) bool { - has, _ := db.GetEngine(ctx). - Where("key_id = ? AND repo_id = ?", keyID, repoID). - Get(new(DeployKey)) - return has -} - // AddDeployKey add new deploy key to database and authorized_keys file. func AddDeployKey(ctx context.Context, repoID int64, name, content string, readOnly bool) (*DeployKey, error) { fingerprint, err := CalcFingerprint(content) diff --git a/models/auth/access_token.go b/models/auth/access_token.go index 63331b48412..7578528be87 100644 --- a/models/auth/access_token.go +++ b/models/auth/access_token.go @@ -98,19 +98,13 @@ func init() { // NewAccessToken creates new access token. func NewAccessToken(ctx context.Context, t *AccessToken) error { - salt, err := util.CryptoRandomString(10) - if err != nil { - return err - } - token, err := util.CryptoRandomBytes(20) - if err != nil { - return err - } + salt := util.CryptoRandomString(10) + token := util.CryptoRandomBytes(20) t.TokenSalt = salt t.Token = hex.EncodeToString(token) t.TokenHash = HashToken(t.Token, t.TokenSalt) t.TokenLastEight = t.Token[len(t.Token)-8:] - _, err = db.GetEngine(ctx).Insert(t) + _, err := db.GetEngine(ctx).Insert(t) return err } diff --git a/models/auth/oauth2.go b/models/auth/oauth2.go index e2bb72b722a..d5a5e2af8e8 100644 --- a/models/auth/oauth2.go +++ b/models/auth/oauth2.go @@ -185,10 +185,7 @@ var base32Lower = base32.NewEncoding(lowerBase32Chars).WithPadding(base32.NoPadd // GenerateClientSecret will generate the client secret and returns the plaintext and saves the hash at the database func (app *OAuth2Application) GenerateClientSecret(ctx context.Context) (string, error) { - rBytes, err := util.CryptoRandomBytes(32) - if err != nil { - return "", err - } + rBytes := util.CryptoRandomBytes(32) // Add a prefix to the base32, this is in order to make it easier // for code scanners to grab sensitive tokens. clientSecret := "gto_" + base32Lower.EncodeToString(rBytes) @@ -220,7 +217,7 @@ func (app *OAuth2Application) GetGrantByUserID(ctx context.Context, userID int64 return grant, nil } -// CreateGrant generates a grant for an user +// CreateGrant generates a grant for a user func (app *OAuth2Application) CreateGrant(ctx context.Context, userID int64, scope string) (*OAuth2Grant, error) { grant := &OAuth2Grant{ ApplicationID: app.ID, @@ -464,7 +461,7 @@ func GetOAuth2AuthorizationByCode(ctx context.Context, code string) (auth *OAuth ////////////////////////////////////////////////////// -// OAuth2Grant represents the permission of an user for a specific application to access resources +// OAuth2Grant represents the permission of a user for a specific application to access resources type OAuth2Grant struct { ID int64 `xorm:"pk autoincr"` UserID int64 `xorm:"INDEX unique(user_application)"` @@ -484,10 +481,7 @@ func (grant *OAuth2Grant) TableName() string { // GenerateNewAuthorizationCode generates a new authorization code for a grant and saves it to the database func (grant *OAuth2Grant) GenerateNewAuthorizationCode(ctx context.Context, redirectURI, codeChallenge, codeChallengeMethod string) (code *OAuth2AuthorizationCode, err error) { - rBytes, err := util.CryptoRandomBytes(32) - if err != nil { - return &OAuth2AuthorizationCode{}, err - } + rBytes := util.CryptoRandomBytes(32) // Add a prefix to the base32, this is in order to make it easier // for code scanners to grab sensitive tokens. codeSecret := "gta_" + base32Lower.EncodeToString(rBytes) @@ -633,7 +627,7 @@ func GetActiveOAuth2SourceByAuthName(ctx context.Context, name string) (*Source, } if !has { - return nil, fmt.Errorf("oauth2 source not found, name: %q", name) + return nil, util.NewNotExistErrorf("oauth2 source not found, name: %q", name) } return authSource, nil diff --git a/models/auth/oauth2_test.go b/models/auth/oauth2_test.go index 88ae065652c..d72e1cb1d52 100644 --- a/models/auth/oauth2_test.go +++ b/models/auth/oauth2_test.go @@ -12,19 +12,30 @@ import ( "code.gitea.io/gitea/modules/timeutil" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestOAuth2AuthorizationCodeValidity(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func TestOAuth2AuthorizationCode(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) t.Run("GenerateSetsValidUntil", func(t *testing.T) { grant := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Grant{ID: 1}) expectedValidUntil := timeutil.TimeStamp(time.Now().Unix() + 600) code, err := grant.GenerateNewAuthorizationCode(t.Context(), "http://127.0.0.1/", "", "") - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, expectedValidUntil, code.ValidUntil) assert.False(t, code.IsExpired()) + assert.Equal(t, int64(1), code.ID) + + code2, err := auth_model.GetOAuth2AuthorizationByCode(t.Context(), code.Code) + require.NoError(t, err) + assert.Equal(t, code.Code, code2.Code) + assert.NoError(t, code.Invalidate(t.Context())) + + code, err = auth_model.GetOAuth2AuthorizationByCode(t.Context(), "does not exist") + require.NoError(t, err) + require.Nil(t, code) }) t.Run("Expired", func(t *testing.T) { @@ -34,13 +45,14 @@ func TestOAuth2AuthorizationCodeValidity(t *testing.T) { assert.True(t, code.IsExpired()) }) - t.Run("InvalidateTwice", func(t *testing.T) { - code, err := auth_model.GetOAuth2AuthorizationByCode(t.Context(), "authcode") - assert.NoError(t, err) - if assert.NotNil(t, code) { - assert.NoError(t, code.Invalidate(t.Context())) - assert.ErrorIs(t, code.Invalidate(t.Context()), auth_model.ErrOAuth2AuthorizationCodeInvalidated) - } + t.Run("Invalidate", func(t *testing.T) { + grant := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Grant{ID: 1}) + code, err := grant.GenerateNewAuthorizationCode(t.Context(), "http://127.0.0.1/", "", "") + require.NoError(t, err) + require.NotNil(t, code) + require.NoError(t, code.Invalidate(t.Context())) + unittest.AssertNotExistsBean(t, &auth_model.OAuth2AuthorizationCode{Code: code.Code}) + assert.ErrorIs(t, code.Invalidate(t.Context()), auth_model.ErrOAuth2AuthorizationCodeInvalidated) }) } @@ -224,19 +236,6 @@ func TestRevokeOAuth2Grant(t *testing.T) { //////////////////// Authorization Code -func TestGetOAuth2AuthorizationByCode(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - code, err := auth_model.GetOAuth2AuthorizationByCode(t.Context(), "authcode") - assert.NoError(t, err) - assert.NotNil(t, code) - assert.Equal(t, "authcode", code.Code) - assert.Equal(t, int64(1), code.ID) - - code, err = auth_model.GetOAuth2AuthorizationByCode(t.Context(), "does not exist") - assert.NoError(t, err) - assert.Nil(t, code) -} - func TestOAuth2AuthorizationCode_ValidateCodeChallenge(t *testing.T) { // test plain code := &auth_model.OAuth2AuthorizationCode{ @@ -284,13 +283,6 @@ func TestOAuth2AuthorizationCode_GenerateRedirectURI(t *testing.T) { assert.Equal(t, "https://example.com/callback?code=thecode", redirect.String()) } -func TestOAuth2AuthorizationCode_Invalidate(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - code := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2AuthorizationCode{Code: "authcode"}) - assert.NoError(t, code.Invalidate(t.Context())) - unittest.AssertNotExistsBean(t, &auth_model.OAuth2AuthorizationCode{Code: "authcode"}) -} - func TestOAuth2AuthorizationCode_TableName(t *testing.T) { assert.Equal(t, "oauth2_authorization_code", new(auth_model.OAuth2AuthorizationCode).TableName()) } diff --git a/models/auth/twofactor.go b/models/auth/twofactor.go index 4263495650f..80c34ba6adb 100644 --- a/models/auth/twofactor.go +++ b/models/auth/twofactor.go @@ -65,14 +65,11 @@ func init() { // GenerateScratchToken recreates the scratch token the user is using. func (t *TwoFactor) GenerateScratchToken() (string, error) { - tokenBytes, err := util.CryptoRandomBytes(6) - if err != nil { - return "", err - } + tokenBytes := util.CryptoRandomBytes(6) // these chars are specially chosen, avoid ambiguous chars like `0`, `O`, `1`, `I`. const base32Chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" token := base32.NewEncoding(base32Chars).WithPadding(base32.NoPadding).EncodeToString(tokenBytes) - t.ScratchSalt, _ = util.CryptoRandomString(10) + t.ScratchSalt = util.CryptoRandomString(10) t.ScratchHash = HashToken(token, t.ScratchSalt) return token, nil } diff --git a/models/auth/webauthn.go b/models/auth/webauthn.go index 6d8b5429579..7bd79ed3f52 100644 --- a/models/auth/webauthn.go +++ b/models/auth/webauthn.go @@ -200,13 +200,3 @@ func DeleteCredential(ctx context.Context, id, userID int64) (bool, error) { had, err := db.GetEngine(ctx).ID(id).Where("user_id = ?", userID).Delete(&WebAuthnCredential{}) return had > 0, err } - -// WebAuthnCredentials implements the webauthn.User interface -func WebAuthnCredentials(ctx context.Context, userID int64) ([]webauthn.Credential, error) { - dbCreds, err := GetWebAuthnCredentialsByUID(ctx, userID) - if err != nil { - return nil, err - } - - return dbCreds.ToCredentials(), nil -} diff --git a/models/db/engine.go b/models/db/engine.go index b08799210e8..fbcc3fa15e8 100755 --- a/models/db/engine.go +++ b/models/db/engine.go @@ -11,11 +11,11 @@ import ( "reflect" "strings" - "xorm.io/xorm" - _ "github.com/go-sql-driver/mysql" // Needed for the MySQL driver _ "github.com/lib/pq" // Needed for the Postgresql driver _ "github.com/microsoft/go-mssqldb" // Needed for the MSSQL driver + + "xorm.io/xorm" ) var ( diff --git a/models/fixtures/access.yml b/models/fixtures/access.yml index 596046e9502..c0aa06c86d2 100644 --- a/models/fixtures/access.yml +++ b/models/fixtures/access.yml @@ -177,3 +177,5 @@ user_id: 40 repo_id: 1 mode: 2 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/access_token.yml b/models/fixtures/access_token.yml index 0744255f664..d85d785da54 100644 --- a/models/fixtures/access_token.yml +++ b/models/fixtures/access_token.yml @@ -31,3 +31,5 @@ created_unix: 946687980 updated_unix: 946687980 # commented out tokens so you can see what they are in plaintext + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/action.yml b/models/fixtures/action.yml index af9ce93ba5c..32f2ae87642 100644 --- a/models/fixtures/action.yml +++ b/models/fixtures/action.yml @@ -73,3 +73,5 @@ is_private: false created_unix: 1680454039 content: '4|' # issueId 5 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/action_artifact.yml b/models/fixtures/action_artifact.yml index a25dfc205c4..5fcc70aa536 100644 --- a/models/fixtures/action_artifact.yml +++ b/models/fixtures/action_artifact.yml @@ -177,3 +177,5 @@ created_unix: 1730330775 updated_unix: 1730330775 expired_unix: 1738106775 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/action_run.yml b/models/fixtures/action_run.yml index ac5e8303c35..1df02e48090 100644 --- a/models/fixtures/action_run.yml +++ b/models/fixtures/action_run.yml @@ -89,7 +89,7 @@ ref: "refs/heads/test" commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" event: "push" - trigger_event: "push" + trigger_event: "schedule" is_fork_pull_request: 0 status: 1 started: 1683636528 @@ -140,23 +140,4 @@ need_approval: 0 approved_by: 0 -- - id: 805 - title: "update actions" - repo_id: 4 - owner_id: 1 - workflow_id: "artifact.yaml" - index: 191 - trigger_user_id: 1 - ref: "refs/heads/master" - commit_sha: "c2d72f548424103f01ee1dc02889c1e2bff816b0" - event: "push" - trigger_event: "push" - is_fork_pull_request: 0 - status: 5 - started: 1683636528 - stopped: 1683636626 - created: 1683636108 - updated: 1683636626 - need_approval: 0 - approved_by: 0 +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/action_run_job.yml b/models/fixtures/action_run_job.yml index 04799b73ca0..2a4e64285f4 100644 --- a/models/fixtures/action_run_job.yml +++ b/models/fixtures/action_run_job.yml @@ -130,17 +130,4 @@ started: 1683636528 stopped: 1683636626 -- - id: 206 - run_id: 805 - repo_id: 4 - owner_id: 1 - commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 - is_fork_pull_request: 0 - name: job_2 - attempt: 1 - job_id: job_2 - task_id: 56 - status: 3 - started: 1683636528 - stopped: 1683636626 +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/action_runner.yml b/models/fixtures/action_runner.yml index ecb72140065..110ff627a2a 100644 --- a/models/fixtures/action_runner.yml +++ b/models/fixtures/action_runner.yml @@ -49,3 +49,5 @@ repo_id: 0 description: "This runner is going to be deleted" agent_labels: '["runner_to_be_deleted","linux"]' + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/action_runner_token.yml b/models/fixtures/action_runner_token.yml index 6520b7f6fbe..3af8a28c9c1 100644 --- a/models/fixtures/action_runner_token.yml +++ b/models/fixtures/action_runner_token.yml @@ -33,3 +33,5 @@ is_active: 1 created: 1695617751 updated: 1695617751 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/action_task.yml b/models/fixtures/action_task.yml index e1bc588dc59..13efe378c43 100644 --- a/models/fixtures/action_task.yml +++ b/models/fixtures/action_task.yml @@ -178,22 +178,4 @@ log_size: 0 log_expired: 0 -- - id: 56 - attempt: 1 - runner_id: 1 - status: 3 # 3 is the status code for "cancelled" - started: 1683636528 - stopped: 1683636626 - repo_id: 4 - owner_id: 1 - commit_sha: c2d72f548424103f01ee1dc02889c1e2bff816b0 - is_fork_pull_request: 0 - token_hash: 6d8ef48297195edcc8e22c70b3020eaa06c52976db67d39b4240c64a69a2cc1508825121b7b8394e48e00b1bf3718b2aaaab - token_salt: eeeeeeee - token_last_eight: eeeeeeee - log_filename: artifact-test2/2f/47.log - log_in_storage: 1 - log_length: 707 - log_size: 90179 - log_expired: 0 +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/action_task_output.yml b/models/fixtures/action_task_output.yml index 314e9f7115b..741b193b13d 100644 --- a/models/fixtures/action_task_output.yml +++ b/models/fixtures/action_task_output.yml @@ -18,3 +18,5 @@ task_id: 50 output_key: output_b output_value: bbb + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/attachment.yml b/models/fixtures/attachment.yml index 570d4a27daf..0870895a715 100644 --- a/models/fixtures/attachment.yml +++ b/models/fixtures/attachment.yml @@ -166,3 +166,5 @@ download_count: 0 size: 0 created_unix: 946684800 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/badge.yml b/models/fixtures/badge.yml index 438cd0ca5d4..72550be79aa 100644 --- a/models/fixtures/badge.yml +++ b/models/fixtures/badge.yml @@ -3,3 +3,5 @@ slug: badge1 description: just a test badge image_url: badge1.png + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/branch.yml b/models/fixtures/branch.yml index a17999091e0..e09022a6142 100644 --- a/models/fixtures/branch.yml +++ b/models/fixtures/branch.yml @@ -249,3 +249,5 @@ is_deleted: false deleted_by_id: 0 deleted_unix: 0 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/collaboration.yml b/models/fixtures/collaboration.yml index 4c3ac367f6b..2de34488090 100644 --- a/models/fixtures/collaboration.yml +++ b/models/fixtures/collaboration.yml @@ -63,3 +63,5 @@ repo_id: 32 user_id: 10 mode: 2 # write + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/comment.yml b/models/fixtures/comment.yml index 8fde386e226..6930dbb58e7 100644 --- a/models/fixtures/comment.yml +++ b/models/fixtures/comment.yml @@ -102,3 +102,5 @@ review_id: 22 assignee_id: 5 created_unix: 946684817 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/commit_status.yml b/models/fixtures/commit_status.yml index 87c652e53ab..df3bc425057 100644 --- a/models/fixtures/commit_status.yml +++ b/models/fixtures/commit_status.yml @@ -57,3 +57,5 @@ context: deploy/awesomeness context_hash: ae9547713a6665fc4261d0756904932085a41cf2 creator_id: 2 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/commit_status_index.yml b/models/fixtures/commit_status_index.yml index f63343b042a..9157911bac2 100644 --- a/models/fixtures/commit_status_index.yml +++ b/models/fixtures/commit_status_index.yml @@ -3,3 +3,5 @@ repo_id: 1 sha: "1234123412341234123412341234123412341234" max_index: 5 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/deploy_key.yml b/models/fixtures/deploy_key.yml index ca780a73aa0..0d1b2f00980 100644 --- a/models/fixtures/deploy_key.yml +++ b/models/fixtures/deploy_key.yml @@ -1 +1,3 @@ [] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/email_address.yml b/models/fixtures/email_address.yml index 0f6bd9ee6df..b3c78120af1 100644 --- a/models/fixtures/email_address.yml +++ b/models/fixtures/email_address.yml @@ -317,3 +317,5 @@ lower_email: user40@example.com is_activated: true is_primary: true + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/external_login_user.yml b/models/fixtures/external_login_user.yml index ca780a73aa0..0d1b2f00980 100644 --- a/models/fixtures/external_login_user.yml +++ b/models/fixtures/external_login_user.yml @@ -1 +1,3 @@ [] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/follow.yml b/models/fixtures/follow.yml index b8d35828bf1..f8de0e039dc 100644 --- a/models/fixtures/follow.yml +++ b/models/fixtures/follow.yml @@ -17,3 +17,5 @@ id: 4 user_id: 31 follow_id: 33 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/gpg_key.yml b/models/fixtures/gpg_key.yml index 2d54313fdf1..3d2895dc1ca 100644 --- a/models/fixtures/gpg_key.yml +++ b/models/fixtures/gpg_key.yml @@ -21,3 +21,5 @@ can_encrypt_comms: true can_encrypt_storage: true can_certify: true + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/gpg_key_import.yml b/models/fixtures/gpg_key_import.yml index ca780a73aa0..0d1b2f00980 100644 --- a/models/fixtures/gpg_key_import.yml +++ b/models/fixtures/gpg_key_import.yml @@ -1 +1,3 @@ [] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/hook_task.yml b/models/fixtures/hook_task.yml index 6023719b1ee..01918b35eeb 100644 --- a/models/fixtures/hook_task.yml +++ b/models/fixtures/hook_task.yml @@ -1,37 +1,2 @@ -- - id: 1 - hook_id: 1 - uuid: uuid1 - is_delivered: true - is_succeed: false - request_content: > - { - "url": "/matrix-delivered", - "http_method":"PUT", - "headers": { - "X-Head": "42" - }, - "body": "{}" - } - -- - id: 2 - hook_id: 1 - uuid: uuid2 - is_delivered: true - -- - id: 3 - hook_id: 1 - uuid: uuid3 - is_delivered: true - is_succeed: true - payload_content: '{"key":"value"}' # legacy task, payload saved in payload_content (and not in request_content) - request_content: > - { - "url": "/matrix-success", - "http_method":"PUT", - "headers": { - "X-Head": "42" - } - } +[] +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/issue.yml b/models/fixtures/issue.yml index ca5b1c6cd1d..6da3c9e279a 100644 --- a/models/fixtures/issue.yml +++ b/models/fixtures/issue.yml @@ -372,3 +372,5 @@ created_unix: 1707270422 updated_unix: 1707270422 is_locked: false + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/issue_assignees.yml b/models/fixtures/issue_assignees.yml index c40ecad6764..a0bf422dc95 100644 --- a/models/fixtures/issue_assignees.yml +++ b/models/fixtures/issue_assignees.yml @@ -18,3 +18,5 @@ id: 5 assignee_id: 10 issue_id: 6 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/issue_index.yml b/models/fixtures/issue_index.yml index 5aabc08e388..51100684471 100644 --- a/models/fixtures/issue_index.yml +++ b/models/fixtures/issue_index.yml @@ -33,3 +33,5 @@ - group_id: 51 max_index: 1 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/issue_label.yml b/models/fixtures/issue_label.yml index f4ecb1f9232..3754bd78286 100644 --- a/models/fixtures/issue_label.yml +++ b/models/fixtures/issue_label.yml @@ -17,3 +17,5 @@ id: 4 issue_id: 2 label_id: 4 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/issue_pin.yml b/models/fixtures/issue_pin.yml index 14b7a72d847..dc3d1c60d99 100644 --- a/models/fixtures/issue_pin.yml +++ b/models/fixtures/issue_pin.yml @@ -4,3 +4,5 @@ issue_id: 4 is_pull: false pin_order: 1 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/issue_user.yml b/models/fixtures/issue_user.yml index 64824316ea2..756cb7be4b4 100644 --- a/models/fixtures/issue_user.yml +++ b/models/fixtures/issue_user.yml @@ -18,3 +18,5 @@ issue_id: 1 is_read: false is_mentioned: true + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/issue_watch.yml b/models/fixtures/issue_watch.yml index 4bc3ff1b8b9..edc1041abc3 100644 --- a/models/fixtures/issue_watch.yml +++ b/models/fixtures/issue_watch.yml @@ -29,3 +29,5 @@ is_watching: false created_unix: 946684800 updated_unix: 946684800 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/label.yml b/models/fixtures/label.yml index acfac749686..064f790a771 100644 --- a/models/fixtures/label.yml +++ b/models/fixtures/label.yml @@ -107,3 +107,5 @@ num_issues: 0 num_closed_issues: 0 archived_unix: 0 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/lfs_meta_object.yml b/models/fixtures/lfs_meta_object.yml index ae5ae565425..0fe430f147b 100644 --- a/models/fixtures/lfs_meta_object.yml +++ b/models/fixtures/lfs_meta_object.yml @@ -30,3 +30,5 @@ size: 25 repository_id: 54 created_unix: 1671607299 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/login_source.yml b/models/fixtures/login_source.yml index ca780a73aa0..0d1b2f00980 100644 --- a/models/fixtures/login_source.yml +++ b/models/fixtures/login_source.yml @@ -1 +1,3 @@ [] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/milestone.yml b/models/fixtures/milestone.yml index 87c30cc96c4..c4ed2aea780 100644 --- a/models/fixtures/milestone.yml +++ b/models/fixtures/milestone.yml @@ -52,3 +52,5 @@ num_closed_issues: 0 completeness: 0 deadline_unix: 253370764800 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/mirror.yml b/models/fixtures/mirror.yml index 97bc4ae60dd..1f690654cb3 100644 --- a/models/fixtures/mirror.yml +++ b/models/fixtures/mirror.yml @@ -47,3 +47,5 @@ next_update_unix: 0 lfs_enabled: false lfs_endpoint: "" + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/notice.yml b/models/fixtures/notice.yml index af08f07bfa1..17e26d7634f 100644 --- a/models/fixtures/notice.yml +++ b/models/fixtures/notice.yml @@ -12,3 +12,5 @@ id: 3 type: 1 # NoticeRepository description: description3 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/notification.yml b/models/fixtures/notification.yml index bd279d4bb28..dcfbeada39d 100644 --- a/models/fixtures/notification.yml +++ b/models/fixtures/notification.yml @@ -52,3 +52,5 @@ issue_id: 4 created_unix: 946688800 updated_unix: 946688820 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/oauth2_application.yml b/models/fixtures/oauth2_application.yml index 2f38cb58b61..3426ccffac4 100644 --- a/models/fixtures/oauth2_application.yml +++ b/models/fixtures/oauth2_application.yml @@ -4,7 +4,7 @@ name: "Test" client_id: "da7da3ba-9a13-4167-856f-3899de0b0138" client_secret: "$2a$10$UYRgUSgekzBp6hYe8pAdc.cgB4Gn06QRKsORUnIYTYQADs.YR/uvi" # bcrypt of "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA= - redirect_uris: '["a", "https://example.com/xyzzy"]' + redirect_uris: '["https://example.com"]' created_unix: 1546869730 updated_unix: 1546869730 confidential_client: true @@ -18,3 +18,5 @@ created_unix: 1546869730 updated_unix: 1546869730 confidential_client: false + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/oauth2_authorization_code.yml b/models/fixtures/oauth2_authorization_code.yml index d29502164e6..01918b35eeb 100644 --- a/models/fixtures/oauth2_authorization_code.yml +++ b/models/fixtures/oauth2_authorization_code.yml @@ -1,15 +1,2 @@ -- id: 1 - grant_id: 1 - code: "authcode" - code_challenge: "CjvyTLSdR47G5zYenDA-eDWW4lRrO8yvjcWwbD_deOg" # Code Verifier: N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt - code_challenge_method: "S256" - redirect_uri: "a" - valid_until: 3546869730 - -- id: 2 - grant_id: 4 - code: "authcodepublic" - code_challenge: "CjvyTLSdR47G5zYenDA-eDWW4lRrO8yvjcWwbD_deOg" # Code Verifier: N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt - code_challenge_method: "S256" - redirect_uri: "http://127.0.0.1/" - valid_until: 3546869730 +[] +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/oauth2_grant.yml b/models/fixtures/oauth2_grant.yml index e63286878b2..54f4e45e62e 100644 --- a/models/fixtures/oauth2_grant.yml +++ b/models/fixtures/oauth2_grant.yml @@ -29,3 +29,5 @@ scope: "whatever" created_unix: 1546869730 updated_unix: 1546869730 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/org_user.yml b/models/fixtures/org_user.yml index 73a3e9dba9b..dc35701182a 100644 --- a/models/fixtures/org_user.yml +++ b/models/fixtures/org_user.yml @@ -135,3 +135,5 @@ uid: 20 org_id: 17 is_public: false + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/project.yml b/models/fixtures/project.yml index 44d87bce046..e61781fd7f1 100644 --- a/models/fixtures/project.yml +++ b/models/fixtures/project.yml @@ -69,3 +69,5 @@ type: 2 created_unix: 1688973000 updated_unix: 1688973000 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/project_board.yml b/models/fixtures/project_board.yml index 3293dea6edf..91d21981714 100644 --- a/models/fixtures/project_board.yml +++ b/models/fixtures/project_board.yml @@ -75,3 +75,5 @@ default: true created_unix: 1588117528 updated_unix: 1588117528 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/project_issue.yml b/models/fixtures/project_issue.yml index b1af05908aa..7d9d5118820 100644 --- a/models/fixtures/project_issue.yml +++ b/models/fixtures/project_issue.yml @@ -21,3 +21,5 @@ issue_id: 5 project_id: 1 project_board_id: 3 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/protected_branch.yml b/models/fixtures/protected_branch.yml index ca780a73aa0..0d1b2f00980 100644 --- a/models/fixtures/protected_branch.yml +++ b/models/fixtures/protected_branch.yml @@ -1 +1,3 @@ [] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/protected_tag.yml b/models/fixtures/protected_tag.yml index 1944e7bd84c..cb83439645e 100644 --- a/models/fixtures/protected_tag.yml +++ b/models/fixtures/protected_tag.yml @@ -22,3 +22,5 @@ allowlist_team_i_ds: "[]" created_unix: 1715596037 updated_unix: 1715596037 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/public_key.yml b/models/fixtures/public_key.yml index 856b0e3fb29..756bca86b61 100644 --- a/models/fixtures/public_key.yml +++ b/models/fixtures/public_key.yml @@ -10,3 +10,5 @@ updated_unix: 1565224552 login_source_id: 0 verified: false + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/pull_request.yml b/models/fixtures/pull_request.yml index 9a16316e5a2..b8da7fe0812 100644 --- a/models/fixtures/pull_request.yml +++ b/models/fixtures/pull_request.yml @@ -117,3 +117,5 @@ index: 1 head_repo_id: 61 base_repo_id: 61 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/reaction.yml b/models/fixtures/reaction.yml index ee571a73a48..9effcc98f7c 100644 --- a/models/fixtures/reaction.yml +++ b/models/fixtures/reaction.yml @@ -37,3 +37,5 @@ comment_id: 2 user_id: 1 created_unix: 1573248005 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/release.yml b/models/fixtures/release.yml index 372a79509f0..be44e331ec0 100644 --- a/models/fixtures/release.yml +++ b/models/fixtures/release.yml @@ -150,3 +150,5 @@ is_prerelease: false is_tag: false created_unix: 946684803 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/renamed_branch.yml b/models/fixtures/renamed_branch.yml index efa5130a2b9..9055080ff85 100644 --- a/models/fixtures/renamed_branch.yml +++ b/models/fixtures/renamed_branch.yml @@ -3,3 +3,5 @@ repo_id: 1 from: dev to: master + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/repo_archiver.yml b/models/fixtures/repo_archiver.yml index ca780a73aa0..0d1b2f00980 100644 --- a/models/fixtures/repo_archiver.yml +++ b/models/fixtures/repo_archiver.yml @@ -1 +1,3 @@ [] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/repo_indexer_status.yml b/models/fixtures/repo_indexer_status.yml index ca780a73aa0..0d1b2f00980 100644 --- a/models/fixtures/repo_indexer_status.yml +++ b/models/fixtures/repo_indexer_status.yml @@ -1 +1,3 @@ [] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/repo_license.yml b/models/fixtures/repo_license.yml index ca780a73aa0..0d1b2f00980 100644 --- a/models/fixtures/repo_license.yml +++ b/models/fixtures/repo_license.yml @@ -1 +1,3 @@ [] # empty + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/repo_redirect.yml b/models/fixtures/repo_redirect.yml index 8850c8d780b..60459d638d6 100644 --- a/models/fixtures/repo_redirect.yml +++ b/models/fixtures/repo_redirect.yml @@ -3,3 +3,5 @@ owner_id: 2 lower_name: oldrepo1 redirect_repo_id: 1 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/repo_topic.yml b/models/fixtures/repo_topic.yml index f166faccc1d..3a4e7edaa96 100644 --- a/models/fixtures/repo_topic.yml +++ b/models/fixtures/repo_topic.yml @@ -25,3 +25,5 @@ - repo_id: 2 topic_id: 6 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/repo_transfer.yml b/models/fixtures/repo_transfer.yml index b12e6b207f4..0a26eaec8ee 100644 --- a/models/fixtures/repo_transfer.yml +++ b/models/fixtures/repo_transfer.yml @@ -29,3 +29,5 @@ repo_id: 5 created_unix: 1553610671 updated_unix: 1553610671 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/repo_unit.yml b/models/fixtures/repo_unit.yml index 4c3e37500f0..69f083ccd75 100644 --- a/models/fixtures/repo_unit.yml +++ b/models/fixtures/repo_unit.yml @@ -747,3 +747,5 @@ type: 10 config: "{}" created_unix: 946684810 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/repository.yml b/models/fixtures/repository.yml index dfa514db37f..d8eb7962072 100644 --- a/models/fixtures/repository.yml +++ b/models/fixtures/repository.yml @@ -1788,3 +1788,5 @@ size: 0 is_fsck_enabled: true close_issues_via_commit_in_any_branch: false + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/review.yml b/models/fixtures/review.yml index 5b8bbceca9e..abcc9d3bb27 100644 --- a/models/fixtures/review.yml +++ b/models/fixtures/review.yml @@ -214,3 +214,5 @@ original_author_id: 0 updated_unix: 946684817 created_unix: 946684817 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/star.yml b/models/fixtures/star.yml index 39b51b3736f..96db493448b 100644 --- a/models/fixtures/star.yml +++ b/models/fixtures/star.yml @@ -17,3 +17,5 @@ id: 4 uid: 10 repo_id: 32 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/stopwatch.yml b/models/fixtures/stopwatch.yml index b7919d6fbbd..bbb3852069c 100644 --- a/models/fixtures/stopwatch.yml +++ b/models/fixtures/stopwatch.yml @@ -9,3 +9,5 @@ user_id: 2 issue_id: 2 created_unix: 1500988002 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/system_setting.yml b/models/fixtures/system_setting.yml index dcad176c899..ae612fa9f93 100644 --- a/models/fixtures/system_setting.yml +++ b/models/fixtures/system_setting.yml @@ -13,3 +13,5 @@ version: 1 created: 1653533198 updated: 1653533198 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/team.yml b/models/fixtures/team.yml index b549d0589bc..3c2cb7802a3 100644 --- a/models/fixtures/team.yml +++ b/models/fixtures/team.yml @@ -261,3 +261,5 @@ num_members: 1 includes_all_repositories: true can_create_org_repo: false + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/team_repo.yml b/models/fixtures/team_repo.yml index a29078107ee..c91f74467a8 100644 --- a/models/fixtures/team_repo.yml +++ b/models/fixtures/team_repo.yml @@ -75,3 +75,5 @@ org_id: 41 team_id: 22 repo_id: 61 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/team_unit.yml b/models/fixtures/team_unit.yml index 110019eee30..bb950870094 100644 --- a/models/fixtures/team_unit.yml +++ b/models/fixtures/team_unit.yml @@ -340,3 +340,5 @@ team_id: 24 type: 1 # code access_mode: 2 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/team_user.yml b/models/fixtures/team_user.yml index 6b2d153278a..4cceffee6a9 100644 --- a/models/fixtures/team_user.yml +++ b/models/fixtures/team_user.yml @@ -159,3 +159,5 @@ org_id: 35 team_id: 24 uid: 2 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/topic.yml b/models/fixtures/topic.yml index 055addf510e..97ac821fc11 100644 --- a/models/fixtures/topic.yml +++ b/models/fixtures/topic.yml @@ -27,3 +27,5 @@ id: 6 name: topicname2 repo_count: 2 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/tracked_time.yml b/models/fixtures/tracked_time.yml index 768af38d9e2..7c2145a6d86 100644 --- a/models/fixtures/tracked_time.yml +++ b/models/fixtures/tracked_time.yml @@ -69,3 +69,5 @@ time: 100000 created_unix: 947688815 deleted: true + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/two_factor.yml b/models/fixtures/two_factor.yml index d8cb85274b6..13b421b7b49 100644 --- a/models/fixtures/two_factor.yml +++ b/models/fixtures/two_factor.yml @@ -7,3 +7,5 @@ last_used_passcode: created_unix: 1564253724 updated_unix: 1564253724 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 976a236011c..1a33947e047 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -1556,3 +1556,5 @@ repo_admin_change_team_access: false theme: "" keep_activity_private: false + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/user_blocking.yml b/models/fixtures/user_blocking.yml index 2ec9d99df52..c1714e40c82 100644 --- a/models/fixtures/user_blocking.yml +++ b/models/fixtures/user_blocking.yml @@ -17,3 +17,5 @@ id: 4 blocker_id: 50 blockee_id: 34 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/user_open_id.yml b/models/fixtures/user_open_id.yml index d3a367b99df..72fe7e34b8c 100644 --- a/models/fixtures/user_open_id.yml +++ b/models/fixtures/user_open_id.yml @@ -15,3 +15,5 @@ uid: 2 uri: https://domain1.tld/user2/ show: true + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/user_redirect.yml b/models/fixtures/user_redirect.yml index c668cb6c3b7..1b0f7a94942 100644 --- a/models/fixtures/user_redirect.yml +++ b/models/fixtures/user_redirect.yml @@ -6,3 +6,5 @@ id: 2 lower_name: olduser2 redirect_user_id: 2 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/watch.yml b/models/fixtures/watch.yml index 18bcd2ed2b4..b7ee121f24d 100644 --- a/models/fixtures/watch.yml +++ b/models/fixtures/watch.yml @@ -39,3 +39,5 @@ user_id: 10 repo_id: 32 mode: 1 # normal + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/webauthn_credential.yml b/models/fixtures/webauthn_credential.yml index bc43127fcd4..d188a4d76a8 100644 --- a/models/fixtures/webauthn_credential.yml +++ b/models/fixtures/webauthn_credential.yml @@ -7,3 +7,5 @@ clone_warning: false created_unix: 946684800 updated_unix: 946684800 + +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/fixtures/webhook.yml b/models/fixtures/webhook.yml index ec282914b81..01918b35eeb 100644 --- a/models/fixtures/webhook.yml +++ b/models/fixtures/webhook.yml @@ -1,52 +1,2 @@ -- - id: 1 - repo_id: 1 - url: https://www.example.com/url1 - content_type: 1 # json - events: '{"push_only":true,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":false}}' - is_active: true - -- - id: 2 - repo_id: 1 - url: https://www.example.com/url2 - content_type: 1 # json - events: '{"push_only":false,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":true}}' - is_active: false - -- - id: 3 - owner_id: 3 - repo_id: 3 - url: https://www.example.com/url3 - content_type: 1 # json - events: '{"push_only":false,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":true}}' - is_active: true - -- - id: 4 - repo_id: 2 - url: https://www.example.com/url4 - content_type: 1 # json - events: '{"push_only":true,"branch_filter":"{master,feature*}"}' - is_active: true - -- - id: 5 - repo_id: 0 - owner_id: 0 - url: https://www.example.com/url5 - content_type: 1 # json - events: '{"push_only":true,"branch_filter":"{master,feature*}"}' - is_active: true - is_system_webhook: true - -- - id: 6 - repo_id: 0 - owner_id: 0 - url: https://www.example.com/url6 - content_type: 1 # json - events: '{"push_only":true,"branch_filter":"{master,feature*}"}' - is_active: true - is_system_webhook: false +[] +# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly diff --git a/models/git/branch_list.go b/models/git/branch_list.go index 25e84526d29..1445f3a5a02 100644 --- a/models/git/branch_list.go +++ b/models/git/branch_list.go @@ -7,7 +7,6 @@ import ( "context" "code.gitea.io/gitea/models/db" - repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" @@ -60,24 +59,6 @@ func (branches BranchList) LoadPusher(ctx context.Context) error { return nil } -func (branches BranchList) LoadRepo(ctx context.Context) error { - ids := container.FilterSlice(branches, func(branch *Branch) (int64, bool) { - return branch.RepoID, branch.RepoID > 0 && branch.Repo == nil - }) - - reposMap := make(map[int64]*repo_model.Repository, len(ids)) - if err := db.GetEngine(ctx).In("id", ids).Find(&reposMap); err != nil { - return err - } - for _, branch := range branches { - if branch.RepoID <= 0 || branch.Repo != nil { - continue - } - branch.Repo = reposMap[branch.RepoID] - } - return nil -} - type FindBranchOptions struct { db.ListOptions RepoID int64 diff --git a/models/issues/comment.go b/models/issues/comment.go index 34ce7f35004..acfc07ff220 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -24,6 +24,7 @@ import ( "code.gitea.io/gitea/modules/htmlutil" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/structs" @@ -399,16 +400,7 @@ func (c *Comment) LoadPoster(ctx context.Context) (err error) { if c.Poster != nil { return nil } - - c.Poster, err = user_model.GetPossibleUserByID(ctx, c.PosterID) - if err != nil { - if user_model.IsErrUserNotExist(err) { - c.PosterID = user_model.GhostUserID - c.Poster = user_model.NewGhostUser() - } else { - log.Error("getUserByID[%d]: %v", c.ID, err) - } - } + c.PosterID, c.Poster, err = user_model.GetPossibleUserByID(ctx, c.PosterID) return err } @@ -543,6 +535,12 @@ func (c *Comment) EventTag() string { return fmt.Sprintf("event-%d", c.ID) } +func (c *Comment) GetSanitizedContentHTML() template.HTML { + // mainly for type=4 CommentTypeCommitRef + // the content is a link like message title (from CreateRefComment) + return markup.Sanitize(c.Content) +} + // LoadLabel if comment.Type is CommentTypeLabel, then load Label func (c *Comment) LoadLabel(ctx context.Context) error { var label Label diff --git a/models/issues/dependency.go b/models/issues/dependency.go index 0eaa47e3593..db8054e1618 100644 --- a/models/issues/dependency.go +++ b/models/issues/dependency.go @@ -89,12 +89,6 @@ type ErrUnknownDependencyType struct { Type DependencyType } -// IsErrUnknownDependencyType checks if an error is ErrUnknownDependencyType -func IsErrUnknownDependencyType(err error) bool { - _, ok := err.(ErrUnknownDependencyType) - return ok -} - func (err ErrUnknownDependencyType) Error() string { return fmt.Sprintf("unknown dependency type [type: %d]", err.Type) } diff --git a/models/issues/issue.go b/models/issues/issue.go index 655cdebdfc6..fe5433fbb20 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -48,21 +48,6 @@ func (err ErrIssueNotExist) Unwrap() error { return util.ErrNotExist } -// ErrNewIssueInsert is used when the INSERT statement in newIssue fails -type ErrNewIssueInsert struct { - OriginalError error -} - -// IsErrNewIssueInsert checks if an error is a ErrNewIssueInsert. -func IsErrNewIssueInsert(err error) bool { - _, ok := err.(ErrNewIssueInsert) - return ok -} - -func (err ErrNewIssueInsert) Error() string { - return err.OriginalError.Error() -} - var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already changed") // Issue represents an issue or pull request of repository. @@ -190,17 +175,10 @@ func (issue *Issue) IsTimetrackerEnabled(ctx context.Context) bool { // LoadPoster loads poster func (issue *Issue) LoadPoster(ctx context.Context) (err error) { - if issue.Poster == nil && issue.PosterID != 0 { - issue.Poster, err = user_model.GetPossibleUserByID(ctx, issue.PosterID) - if err != nil { - issue.PosterID = user_model.GhostUserID - issue.Poster = user_model.NewGhostUser() - if !user_model.IsErrUserNotExist(err) { - return fmt.Errorf("getUserByID.(poster) [%d]: %w", issue.PosterID, err) - } - return nil - } + if issue.Poster != nil { + return nil } + issue.PosterID, issue.Poster, err = user_model.GetPossibleUserByID(ctx, issue.PosterID) return err } @@ -592,6 +570,17 @@ func GetIssueByID(ctx context.Context, id int64) (*Issue, error) { return issue, nil } +func GetIssueByRepoID(ctx context.Context, repoID, issueID int64) (*Issue, error) { + issue := new(Issue) + has, err := db.GetEngine(ctx).ID(issueID).Where("repo_id=?", repoID).Get(issue) + if err != nil { + return nil, err + } else if !has { + return nil, ErrIssueNotExist{issueID, repoID, 0} + } + return issue, nil +} + // GetIssuesByIDs return issues with the given IDs. // If keepOrder is true, the order of the returned issues will be the same as the given IDs. func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) { diff --git a/models/issues/issue_pin.go b/models/issues/issue_pin.go index ae6195b05dd..753c96ed180 100644 --- a/models/issues/issue_pin.go +++ b/models/issues/issue_pin.go @@ -165,27 +165,6 @@ func MovePin(ctx context.Context, issue *Issue, newPosition int) error { }) } -func GetPinnedIssueIDs(ctx context.Context, repoID int64, isPull bool) ([]int64, error) { - var issuePins []IssuePin - if err := db.GetEngine(ctx). - Table("issue_pin"). - Where("repo_id = ?", repoID). - And("is_pull = ?", isPull). - Find(&issuePins); err != nil { - return nil, err - } - - sort.Slice(issuePins, func(i, j int) bool { - return issuePins[i].PinOrder < issuePins[j].PinOrder - }) - - var ids []int64 - for _, pin := range issuePins { - ids = append(ids, pin.IssueID) - } - return ids, nil -} - func GetIssuePinsByRepoID(ctx context.Context, repoID int64, isPull bool) ([]*IssuePin, error) { var pins []*IssuePin if err := db.GetEngine(ctx).Where("repo_id = ? AND is_pull = ?", repoID, isPull).Find(&pins); err != nil { diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go index 3bb09363019..f78daf77f88 100644 --- a/models/issues/issue_project.go +++ b/models/issues/issue_project.go @@ -115,17 +115,10 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo panic("newColumnID must not be zero") // shouldn't happen } - res := struct { - MaxSorting int64 - IssueCount int64 - }{} - if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue"). - Where("project_id=?", newProjectID). - And("project_board_id=?", newColumnID). - Get(&res); err != nil { + newSorting, err := project_model.GetColumnIssueNextSorting(ctx, newProjectID, newColumnID) + if err != nil { return err } - newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) return db.Insert(ctx, &project_model.ProjectIssue{ IssueID: issue.ID, ProjectID: newProjectID, diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 01a3eb9a2af..c58a2f319dd 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -93,12 +93,6 @@ type ErrIssueIsOpen struct { Index int64 } -// IsErrIssueIsOpen checks if an error is a ErrIssueIsOpen. -func IsErrIssueIsOpen(err error) bool { - _, ok := err.(ErrIssueIsOpen) - return ok -} - func (err ErrIssueIsOpen) Error() string { return fmt.Sprintf("%s [id: %d, repo_id: %d, index: %d] is already open", util.Iif(err.IsPull, "Pull Request", "Issue"), err.ID, err.RepoID, err.Index) } @@ -441,7 +435,7 @@ func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *Issue, la LabelIDs: labelIDs, Attachments: uuids, }); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) { + if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { return err } return fmt.Errorf("newIssue: %w", err) diff --git a/models/issues/issue_watch.go b/models/issues/issue_watch.go index 560be17eb62..f384e086e56 100644 --- a/models/issues/issue_watch.go +++ b/models/issues/issue_watch.go @@ -67,7 +67,7 @@ func GetIssueWatch(ctx context.Context, userID, issueID int64) (iw *IssueWatch, return iw, exists, err } -// CheckIssueWatch check if an user is watching an issue +// CheckIssueWatch check if a user is watching an issue // it takes participants and repo watch into account func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) (bool, error) { iw, exist, err := GetIssueWatch(ctx, user.ID, issue.ID) diff --git a/models/issues/pull.go b/models/issues/pull.go index c07044f3011..4c8d02a990e 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -437,8 +437,8 @@ func (pr *PullRequest) IsChecking() bool { return pr.Status == PullRequestStatusChecking } -// CanAutoMerge returns true if this pull request can be merged automatically. -func (pr *PullRequest) CanAutoMerge() bool { +// IsStatusMergeable returns true if this pull request is mergeable to its base +func (pr *PullRequest) IsStatusMergeable() bool { return pr.Status == PullRequestStatusMergeable } @@ -475,7 +475,7 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, issue *Iss LabelIDs: labelIDs, Attachments: uuids, }); err != nil { - if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewIssueInsert(err) { + if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { return err } return fmt.Errorf("newIssue: %w", err) @@ -877,7 +877,12 @@ func ParseCodeOwnersLine(ctx context.Context, tokens []string) (*CodeOwnerRule, warnings := make([]string, 0) - expr := fmt.Sprintf("^%s$", strings.TrimPrefix(tokens[0], "!")) + // Strip leading "!" for negative rules, then strip leading "/" since + // git returns relative paths (e.g. "docs/foo.md" not "/docs/foo.md") + // and the regex is already anchored with ^...$, so the "/" is redundant. + pattern := strings.TrimPrefix(tokens[0], "!") + pattern = strings.TrimPrefix(pattern, "/") + expr := fmt.Sprintf("^%s$", pattern) rule.Rule, err = regexp2.Compile(expr, regexp2.None) if err != nil { warnings = append(warnings, fmt.Sprintf("incorrect codeowner regexp: %s", err)) diff --git a/models/issues/pull_test.go b/models/issues/pull_test.go index 25b27cbe9c9..79d1f8aa9b4 100644 --- a/models/issues/pull_test.go +++ b/models/issues/pull_test.go @@ -17,16 +17,43 @@ import ( "github.com/stretchr/testify/require" ) -func TestPullRequest_LoadAttributes(t *testing.T) { +func TestPullRequest(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) + + t.Run("LoadAttributes", testPullRequestLoadAttributes) + t.Run("LoadIssue", testPullRequestLoadIssue) + t.Run("LoadBaseRepo", testPullRequestLoadBaseRepo) + t.Run("LoadHeadRepo", testPullRequestLoadHeadRepo) + t.Run("PullRequestsNewest", testPullRequestsNewest) + t.Run("PullRequestsOldest", testPullRequestsOldest) + t.Run("GetUnmergedPullRequest", testGetUnmergedPullRequest) + t.Run("HasUnmergedPullRequestsByHeadInfo", testHasUnmergedPullRequestsByHeadInfo) + t.Run("GetUnmergedPullRequestsByHeadInfo", testGetUnmergedPullRequestsByHeadInfo) + t.Run("GetUnmergedPullRequestsByBaseInfo", testGetUnmergedPullRequestsByBaseInfo) + t.Run("GetPullRequestByIndex", testGetPullRequestByIndex) + t.Run("GetPullRequestByID", testGetPullRequestByID) + t.Run("GetPullRequestByIssueID", testGetPullRequestByIssueID) + t.Run("PullRequest_UpdateCols", testPullRequestUpdateCols) + t.Run("PullRequest_IsWorkInProgress", testPullRequestIsWorkInProgress) + t.Run("PullRequest_GetWorkInProgressPrefixWorkInProgress", testPullRequestGetWorkInProgressPrefixWorkInProgress) + t.Run("DeleteOrphanedObjects", testDeleteOrphanedObjects) + t.Run("ParseCodeOwnersLine", testParseCodeOwnersLine) + t.Run("CodeOwnerAbsolutePathPatterns", testCodeOwnerAbsolutePathPatterns) + t.Run("GetApprovers", testGetApprovers) + t.Run("GetPullRequestByMergedCommit", testGetPullRequestByMergedCommit) + t.Run("Migrate_InsertPullRequests", testMigrateInsertPullRequests) + t.Run("PullRequestsClosedRecentSortType", testPullRequestsClosedRecentSortType) + t.Run("LoadRequestedReviewers", testLoadRequestedReviewers) +} + +func testPullRequestLoadAttributes(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) assert.NoError(t, pr.LoadAttributes(t.Context())) assert.NotNil(t, pr.Merger) assert.Equal(t, pr.MergerID, pr.Merger.ID) } -func TestPullRequest_LoadIssue(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testPullRequestLoadIssue(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) assert.NoError(t, pr.LoadIssue(t.Context())) assert.NotNil(t, pr.Issue) @@ -36,8 +63,7 @@ func TestPullRequest_LoadIssue(t *testing.T) { assert.Equal(t, int64(2), pr.Issue.ID) } -func TestPullRequest_LoadBaseRepo(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testPullRequestLoadBaseRepo(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) assert.NoError(t, pr.LoadBaseRepo(t.Context())) assert.NotNil(t, pr.BaseRepo) @@ -47,8 +73,7 @@ func TestPullRequest_LoadBaseRepo(t *testing.T) { assert.Equal(t, pr.BaseRepoID, pr.BaseRepo.ID) } -func TestPullRequest_LoadHeadRepo(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testPullRequestLoadHeadRepo(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) assert.NoError(t, pr.LoadHeadRepo(t.Context())) assert.NotNil(t, pr.HeadRepo) @@ -59,8 +84,7 @@ func TestPullRequest_LoadHeadRepo(t *testing.T) { // TODO TestNewPullRequest -func TestPullRequestsNewest(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testPullRequestsNewest(t *testing.T) { prs, count, err := issues_model.PullRequests(t.Context(), 1, &issues_model.PullRequestsOptions{ ListOptions: db.ListOptions{ Page: 1, @@ -77,7 +101,7 @@ func TestPullRequestsNewest(t *testing.T) { } } -func TestPullRequests_Closed_RecentSortType(t *testing.T) { +func testPullRequestsClosedRecentSortType(t *testing.T) { // Issue ID | Closed At. | Updated At // 2 | 1707270001 | 1707270001 // 3 | 1707271000 | 1707279999 @@ -90,7 +114,6 @@ func TestPullRequests_Closed_RecentSortType(t *testing.T) { {"recentclose", []int64{11, 3, 2}}, } - assert.NoError(t, unittest.PrepareTestDatabase()) _, err := db.Exec(t.Context(), "UPDATE issue SET closed_unix = 1707270001, updated_unix = 1707270001, is_closed = true WHERE id = 2") require.NoError(t, err) _, err = db.Exec(t.Context(), "UPDATE issue SET closed_unix = 1707271000, updated_unix = 1707279999, is_closed = true WHERE id = 3") @@ -118,9 +141,7 @@ func TestPullRequests_Closed_RecentSortType(t *testing.T) { } } -func TestLoadRequestedReviewers(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - +func testLoadRequestedReviewers(t *testing.T) { pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) assert.NoError(t, pull.LoadIssue(t.Context())) issue := pull.Issue @@ -146,8 +167,7 @@ func TestLoadRequestedReviewers(t *testing.T) { assert.Empty(t, pull.RequestedReviewers) } -func TestPullRequestsOldest(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testPullRequestsOldest(t *testing.T) { prs, count, err := issues_model.PullRequests(t.Context(), 1, &issues_model.PullRequestsOptions{ ListOptions: db.ListOptions{ Page: 1, @@ -164,8 +184,7 @@ func TestPullRequestsOldest(t *testing.T) { } } -func TestGetUnmergedPullRequest(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testGetUnmergedPullRequest(t *testing.T) { pr, err := issues_model.GetUnmergedPullRequest(t.Context(), 1, 1, "branch2", "master", issues_model.PullRequestFlowGithub) assert.NoError(t, err) assert.Equal(t, int64(2), pr.ID) @@ -175,9 +194,7 @@ func TestGetUnmergedPullRequest(t *testing.T) { assert.True(t, issues_model.IsErrPullRequestNotExist(err)) } -func TestHasUnmergedPullRequestsByHeadInfo(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - +func testHasUnmergedPullRequestsByHeadInfo(t *testing.T) { exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(t.Context(), 1, "branch2") assert.NoError(t, err) assert.True(t, exist) @@ -187,8 +204,7 @@ func TestHasUnmergedPullRequestsByHeadInfo(t *testing.T) { assert.False(t, exist) } -func TestGetUnmergedPullRequestsByHeadInfo(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testGetUnmergedPullRequestsByHeadInfo(t *testing.T) { prs, err := issues_model.GetUnmergedPullRequestsByHeadInfo(t.Context(), 1, "branch2") assert.NoError(t, err) assert.Len(t, prs, 1) @@ -198,8 +214,7 @@ func TestGetUnmergedPullRequestsByHeadInfo(t *testing.T) { } } -func TestGetUnmergedPullRequestsByBaseInfo(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testGetUnmergedPullRequestsByBaseInfo(t *testing.T) { prs, err := issues_model.GetUnmergedPullRequestsByBaseInfo(t.Context(), 1, "master") assert.NoError(t, err) assert.Len(t, prs, 1) @@ -209,8 +224,7 @@ func TestGetUnmergedPullRequestsByBaseInfo(t *testing.T) { assert.Equal(t, "master", pr.BaseBranch) } -func TestGetPullRequestByIndex(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testGetPullRequestByIndex(t *testing.T) { pr, err := issues_model.GetPullRequestByIndex(t.Context(), 1, 2) assert.NoError(t, err) assert.Equal(t, int64(1), pr.BaseRepoID) @@ -225,8 +239,7 @@ func TestGetPullRequestByIndex(t *testing.T) { assert.True(t, issues_model.IsErrPullRequestNotExist(err)) } -func TestGetPullRequestByID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testGetPullRequestByID(t *testing.T) { pr, err := issues_model.GetPullRequestByID(t.Context(), 1) assert.NoError(t, err) assert.Equal(t, int64(1), pr.ID) @@ -237,8 +250,7 @@ func TestGetPullRequestByID(t *testing.T) { assert.True(t, issues_model.IsErrPullRequestNotExist(err)) } -func TestGetPullRequestByIssueID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testGetPullRequestByIssueID(t *testing.T) { pr, err := issues_model.GetPullRequestByIssueID(t.Context(), 2) assert.NoError(t, err) assert.Equal(t, int64(2), pr.IssueID) @@ -248,8 +260,7 @@ func TestGetPullRequestByIssueID(t *testing.T) { assert.True(t, issues_model.IsErrPullRequestNotExist(err)) } -func TestPullRequest_UpdateCols(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testPullRequestUpdateCols(t *testing.T) { pr := &issues_model.PullRequest{ ID: 1, BaseBranch: "baseBranch", @@ -265,9 +276,7 @@ func TestPullRequest_UpdateCols(t *testing.T) { // TODO TestAddTestPullRequestTask -func TestPullRequest_IsWorkInProgress(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - +func testPullRequestIsWorkInProgress(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) pr.LoadIssue(t.Context()) @@ -280,9 +289,7 @@ func TestPullRequest_IsWorkInProgress(t *testing.T) { assert.True(t, pr.IsWorkInProgress(t.Context())) } -func TestPullRequest_GetWorkInProgressPrefixWorkInProgress(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - +func testPullRequestGetWorkInProgressPrefixWorkInProgress(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2}) pr.LoadIssue(t.Context()) @@ -296,9 +303,7 @@ func TestPullRequest_GetWorkInProgressPrefixWorkInProgress(t *testing.T) { assert.Equal(t, "[wip]", pr.GetWorkInProgressPrefix(t.Context())) } -func TestDeleteOrphanedObjects(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - +func testDeleteOrphanedObjects(t *testing.T) { countBefore, err := db.GetEngine(t.Context()).Count(&issues_model.PullRequest{}) assert.NoError(t, err) @@ -317,7 +322,7 @@ func TestDeleteOrphanedObjects(t *testing.T) { assert.Equal(t, countBefore, countAfter) } -func TestParseCodeOwnersLine(t *testing.T) { +func testParseCodeOwnersLine(t *testing.T) { type CodeOwnerTest struct { Line string Tokens []string @@ -331,6 +336,8 @@ func TestParseCodeOwnersLine(t *testing.T) { {Line: `docs/(aws|google|azure)/[^/]*\\.(md|txt) @org3 @org2/team2`, Tokens: []string{`docs/(aws|google|azure)/[^/]*\.(md|txt)`, "@org3", "@org2/team2"}}, {Line: `\#path @org3`, Tokens: []string{`#path`, "@org3"}}, {Line: `path\ with\ spaces/ @org3`, Tokens: []string{`path with spaces/`, "@org3"}}, + {Line: `/docs/.*\\.md @user1`, Tokens: []string{`/docs/.*\.md`, "@user1"}}, + {Line: `!/assets/.*\\.(bin|exe|msi) @user1`, Tokens: []string{`!/assets/.*\.(bin|exe|msi)`, "@user1"}}, } for _, g := range given { @@ -339,8 +346,37 @@ func TestParseCodeOwnersLine(t *testing.T) { } } -func TestGetApprovers(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testCodeOwnerAbsolutePathPatterns(t *testing.T) { + type testCase struct { + content string + file string + expected bool + } + + cases := []testCase{ + // Absolute path pattern should match (leading "/" stripped) + {content: "/README.md @user5\n", file: "README.md", expected: true}, + // Absolute path pattern in subdirectory + {content: "/docs/.* @user5\n", file: "docs/foo.md", expected: true}, + // Absolute path should not match nested paths it shouldn't + {content: "/docs/.* @user5\n", file: "other/docs/foo.md", expected: false}, + // Relative path still works + {content: "README.md @user5\n", file: "README.md", expected: true}, + // Negated absolute path pattern + {content: "!/.* @user5\n", file: "README.md", expected: false}, + } + + for _, c := range cases { + rules, _ := issues_model.GetCodeOwnersFromContent(t.Context(), c.content) + require.NotEmpty(t, rules) + rule := rules[0] + regexpMatched, _ := rule.Rule.MatchString(c.file) + ruleMatched := regexpMatched == !rule.Negative + assert.Equal(t, c.expected, ruleMatched, "pattern %q against file %q", c.content, c.file) + } +} + +func testGetApprovers(t *testing.T) { pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 5}) // Official reviews are already deduplicated. Allow unofficial reviews // to assert that there are no duplicated approvers. @@ -350,8 +386,7 @@ func TestGetApprovers(t *testing.T) { assert.Equal(t, expected, approvers) } -func TestGetPullRequestByMergedCommit(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testGetPullRequestByMergedCommit(t *testing.T) { pr, err := issues_model.GetPullRequestByMergedCommit(t.Context(), 1, "1a8823cd1a9549fde083f992f6b9b87a7ab74fb3") assert.NoError(t, err) assert.EqualValues(t, 1, pr.ID) @@ -362,8 +397,7 @@ func TestGetPullRequestByMergedCommit(t *testing.T) { assert.ErrorAs(t, err, &issues_model.ErrPullRequestNotExist{}) } -func TestMigrate_InsertPullRequests(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testMigrateInsertPullRequests(t *testing.T) { reponame := "repo1" repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) diff --git a/models/issues/review.go b/models/issues/review.go index af2600876ed..78ef0d20c23 100644 --- a/models/issues/review.go +++ b/models/issues/review.go @@ -67,7 +67,7 @@ func (err ErrNotValidReviewRequest) Unwrap() error { return util.ErrInvalidArgument } -// ErrReviewRequestOnClosedPR represents an error when an user tries to request a re-review on a closed or merged PR. +// ErrReviewRequestOnClosedPR represents an error when a user tries to request a re-review on a closed or merged PR. type ErrReviewRequestOnClosedPR struct{} // IsErrReviewRequestOnClosedPR checks if an error is an ErrReviewRequestOnClosedPR. @@ -176,15 +176,7 @@ func (r *Review) LoadReviewer(ctx context.Context) (err error) { if r.ReviewerID == 0 || r.Reviewer != nil { return err } - r.Reviewer, err = user_model.GetPossibleUserByID(ctx, r.ReviewerID) - if err != nil { - if !user_model.IsErrUserNotExist(err) { - return fmt.Errorf("GetPossibleUserByID [%d]: %w", r.ReviewerID, err) - } - r.ReviewerID = user_model.GhostUserID - r.Reviewer = user_model.NewGhostUser() - return nil - } + r.ReviewerID, r.Reviewer, err = user_model.GetPossibleUserByID(ctx, r.ReviewerID) return err } @@ -908,8 +900,8 @@ func MarkConversation(ctx context.Context, comment *Comment, doer *user_model.Us // CanMarkConversation Add or remove Conversation mark for a code comment permission check // the PR writer , official reviewer and poster can do it func CanMarkConversation(ctx context.Context, issue *Issue, doer *user_model.User) (permResult bool, err error) { - if doer == nil || issue == nil { - return false, errors.New("issue or doer is nil") + if doer == nil { + return false, nil } if err = issue.LoadRepo(ctx); err != nil { diff --git a/models/migrations/base/tests.go b/models/migrations/base/tests.go index 17ea951b5a6..7482829f1f2 100644 --- a/models/migrations/base/tests.go +++ b/models/migrations/base/tests.go @@ -75,7 +75,7 @@ func deleteDB() error { } db.Close() - // Check if we need to setup a specific schema + // Check if we need to set up a specific schema if len(setting.Database.Schema) != 0 { db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) @@ -91,7 +91,7 @@ func deleteDB() error { defer schrows.Close() if !schrows.Next() { - // Create and setup a DB schema + // Create and set up a DB schema _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema) if err != nil { return err @@ -134,7 +134,8 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu ourSkip := 2 ourSkip += skip deferFn := testlogger.PrintCurrentTest(t, ourSkip) - require.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath)) + giteaRoot := setting.GetGiteaTestSourceRoot() + require.NoError(t, unittest.SyncDirs(filepath.Join(giteaRoot, "tests/gitea-repositories-meta"), setting.RepoRootPath)) if err := deleteDB(); err != nil { t.Fatalf("unable to reset database: %v", err) @@ -166,7 +167,7 @@ func PrepareTestEnv(t *testing.T, skip int, syncModels ...any) (*xorm.Engine, fu } } - fixturesDir := filepath.Join(filepath.Dir(setting.AppPath), "models", "migrations", "fixtures", t.Name()) + fixturesDir := filepath.Join(giteaRoot, "models", "migrations", "fixtures", t.Name()) if _, err := os.Stat(fixturesDir); err == nil { t.Logf("initializing fixtures from: %s", fixturesDir) @@ -203,17 +204,17 @@ func LoadTableSchemasMap(t *testing.T, x *xorm.Engine) map[string]*schemas.Table func mainTest(m *testing.M) int { testlogger.Init() - tmpDataPath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("data") + tempWorkPath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("migration-test-data-") if err != nil { - testlogger.Panicf("Unable to create temporary data path %v\n", err) + return testlogger.MainErrorf("Unable to create temporary dir for migration test: %v", err) } defer cleanup() - setting.AppDataPath = tmpDataPath + setting.MockBuiltinPaths(tempWorkPath, "", "") + setting.SetupGiteaTestEnv() - unittest.InitSettingsForTesting() if err = git.InitFull(); err != nil { - testlogger.Panicf("Unable to InitFull: %v\n", err) + return testlogger.MainErrorf("Unable to InitFull: %v", err) } setting.LoadDBSetting() setting.InitLoggersForTest() diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index db74ff78d50..c3a8f08b5d7 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/models/migrations/v1_24" "code.gitea.io/gitea/models/migrations/v1_25" "code.gitea.io/gitea/models/migrations/v1_26" + "code.gitea.io/gitea/models/migrations/v1_27" "code.gitea.io/gitea/models/migrations/v1_6" "code.gitea.io/gitea/models/migrations/v1_7" "code.gitea.io/gitea/models/migrations/v1_8" @@ -405,6 +406,9 @@ func prepareMigrationTasks() []*migration { newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob), newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge), newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook), + // Gitea 1.26.0 ends at migration ID number 330 (database version 331) + + newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), } return preparedMigrations } diff --git a/models/migrations/v1_19/v233.go b/models/migrations/v1_19/v233.go index 9eb6d405099..44ced874b32 100644 --- a/models/migrations/v1_19/v233.go +++ b/models/migrations/v1_19/v233.go @@ -9,7 +9,6 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/secret" "code.gitea.io/gitea/modules/setting" - api "code.gitea.io/gitea/modules/structs" "xorm.io/builder" "xorm.io/xorm" @@ -129,11 +128,11 @@ func AddHeaderAuthorizationEncryptedColWebhook(x *xorm.Engine) error { } type MatrixPayloadSafe struct { - Body string `json:"body"` - MsgType string `json:"msgtype"` - Format string `json:"format"` - FormattedBody string `json:"formatted_body"` - Commits []*api.PayloadCommit `json:"io.gitea.commits,omitempty"` + Body string `json:"body"` + MsgType string `json:"msgtype"` + Format string `json:"format"` + FormattedBody string `json:"formatted_body"` + Commits json.Value `json:"io.gitea.commits,omitempty"` } type MatrixPayloadUnsafe struct { MatrixPayloadSafe diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go index 76532e2f858..dcf548bec0e 100644 --- a/models/migrations/v1_26/v326.go +++ b/models/migrations/v1_26/v326.go @@ -13,7 +13,6 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" - api "code.gitea.io/gitea/modules/structs" webhook_module "code.gitea.io/gitea/modules/webhook" "xorm.io/xorm" @@ -59,6 +58,29 @@ type migrationCommitStatus struct { TargetURL string } +// Frozen subsets of modules/structs payload types, decoded from stored +// action_run.event_payload values. Inlined so the migration is insulated +// from future field changes in modules/structs. +type migrationPayloadCommit struct { + ID string `json:"id"` +} + +type migrationPushPayload struct { + HeadCommit *migrationPayloadCommit `json:"head_commit"` +} + +type migrationPRBranchInfo struct { + Sha string `json:"sha"` +} + +type migrationPullRequest struct { + Head *migrationPRBranchInfo `json:"head"` +} + +type migrationPullRequestPayload struct { + PullRequest *migrationPullRequest `json:"pull_request"` +} + type commitSHAAndRuns struct { commitSHA string runs map[int64]*migrationActionRun @@ -292,22 +314,22 @@ func getCommitStatusCommitID(run *migrationActionRun) (string, error) { } } -func getPushEventPayload(run *migrationActionRun) (*api.PushPayload, error) { +func getPushEventPayload(run *migrationActionRun) (*migrationPushPayload, error) { if run.Event != webhook_module.HookEventPush { return nil, fmt.Errorf("event %s is not a push event", run.Event) } - var payload api.PushPayload + var payload migrationPushPayload if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil { return nil, err } return &payload, nil } -func getPullRequestEventPayload(run *migrationActionRun) (*api.PullRequestPayload, error) { +func getPullRequestEventPayload(run *migrationActionRun) (*migrationPullRequestPayload, error) { if !run.Event.IsPullRequest() && !run.Event.IsPullRequestReview() { return nil, fmt.Errorf("event %s is not a pull request event", run.Event) } - var payload api.PullRequestPayload + var payload migrationPullRequestPayload if err := json.Unmarshal([]byte(run.EventPayload), &payload); err != nil { return nil, err } diff --git a/models/migrations/v1_27/main_test.go b/models/migrations/v1_27/main_test.go new file mode 100644 index 00000000000..e269e3df9a8 --- /dev/null +++ b/models/migrations/v1_27/main_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "testing" + + "code.gitea.io/gitea/models/migrations/base" +) + +func TestMain(m *testing.M) { + base.MainTest(m) +} diff --git a/models/migrations/v1_27/v331.go b/models/migrations/v1_27/v331.go new file mode 100644 index 00000000000..204b7b661e9 --- /dev/null +++ b/models/migrations/v1_27/v331.go @@ -0,0 +1,158 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + "time" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +type actionRunAttempt struct { + ID int64 + RepoID int64 `xorm:"index(repo_concurrency_status)"` + RunID int64 `xorm:"UNIQUE(run_attempt)"` + Attempt int64 `xorm:"UNIQUE(run_attempt)"` + TriggerUserID int64 + ConcurrencyGroup string `xorm:"index(repo_concurrency_status) NOT NULL DEFAULT ''"` + ConcurrencyCancel bool `xorm:"NOT NULL DEFAULT FALSE"` + Status int `xorm:"index(repo_concurrency_status)"` + Started timeutil.TimeStamp + Stopped timeutil.TimeStamp + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` +} + +func (actionRunAttempt) TableName() string { + return "action_run_attempt" +} + +type actionArtifact struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index unique(runid_attempt_name_path)"` + RunAttemptID int64 `xorm:"index unique(runid_attempt_name_path) NOT NULL DEFAULT 0"` + RunnerID int64 + RepoID int64 `xorm:"index"` + OwnerID int64 + CommitSHA string + StoragePath string + FileSize int64 + FileCompressedSize int64 + ContentEncoding string `xorm:"content_encoding"` + ArtifactPath string `xorm:"index unique(runid_attempt_name_path)"` + ArtifactName string `xorm:"index unique(runid_attempt_name_path)"` + Status int `xorm:"index"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated index"` + ExpiredUnix timeutil.TimeStamp `xorm:"index"` +} + +func (actionArtifact) TableName() string { + return "action_artifact" +} + +// actionRun mirrors the post-migration action_run schema. +type actionRun struct { + ID int64 + Title string + RepoID int64 `xorm:"unique(repo_index)"` + OwnerID int64 `xorm:"index"` + WorkflowID string `xorm:"index"` + Index int64 `xorm:"index unique(repo_index)"` + TriggerUserID int64 `xorm:"index"` + ScheduleID int64 + Ref string `xorm:"index"` + CommitSHA string + IsForkPullRequest bool + NeedApproval bool + ApprovedBy int64 `xorm:"index"` + Event string + EventPayload string `xorm:"LONGTEXT"` + TriggerEvent string + Status int `xorm:"index"` + Version int `xorm:"version default 0"` + RawConcurrency string + Started timeutil.TimeStamp + Stopped timeutil.TimeStamp + PreviousDuration time.Duration + LatestAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"` + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` +} + +func (actionRun) TableName() string { + return "action_run" +} + +// AddActionRunAttemptModel adds the ActionRunAttempt table and the supporting ActionRun/ActionRunJob fields. +func AddActionRunAttemptModel(x *xorm.Engine) error { + // add "action_run_attempt" + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(actionRunAttempt)); err != nil { + return err + } + + // update "action_run_job" + type ActionRunJob struct { + RunAttemptID int64 `xorm:"index NOT NULL DEFAULT 0"` + AttemptJobID int64 `xorm:"index NOT NULL DEFAULT 0"` + SourceTaskID int64 `xorm:"NOT NULL DEFAULT 0"` + } + if _, err := x.SyncWithOptions(xorm.SyncOptions{ + IgnoreDropIndices: true, + }, new(ActionRunJob)); err != nil { + return err + } + + // update "action_artifact": let xorm sync add the new 4-column unique index (runid_attempt_name_path) and drop the old 3-column unique (runid_name_path) + if err := x.Sync(new(actionArtifact)); err != nil { + return err + } + + // update "action_run" + // + // This migration intentionally removes the legacy run-level concurrency columns after + // introducing attempt-level concurrency on action_run_attempt. + // + // Existing values from action_run.concurrency_group / action_run.concurrency_cancel are + // not backfilled into action_run_attempt: + // - the old fields are only meaningful while a run is actively participating in + // concurrency scheduling + // - for completed legacy runs, keeping or backfilling those values has no practical + // effect on future scheduling behavior + // - scanning and backfilling old runs would add significant migration cost for little value + // + // This means the schema change is destructive for those two legacy columns by design. + // + // Let xorm sync add the latest_attempt_id column and drop the now-orphan (repo_id, concurrency_group) index. + if err := x.Sync(new(actionRun)); err != nil { + return err + } + concurrencyColumns := make([]string, 0, 2) + for _, col := range []string{"concurrency_group", "concurrency_cancel"} { + exist, err := x.Dialect().IsColumnExist(x.DB(), context.Background(), "action_run", col) + if err != nil { + return err + } + if exist { + concurrencyColumns = append(concurrencyColumns, col) + } + } + if len(concurrencyColumns) == 0 { + return nil + } + sess := x.NewSession() + defer sess.Close() + if err := base.DropTableColumns(sess, "action_run", concurrencyColumns...); err != nil { + return err + } + // DropTableColumns rebuilds the table on SQLite, which drops all existing indexes. + // Re-sync to restore the indexes defined on actionRun. + return x.Sync(new(actionRun)) +} diff --git a/models/migrations/v1_27/v331_test.go b/models/migrations/v1_27/v331_test.go new file mode 100644 index 00000000000..45f467cf9bc --- /dev/null +++ b/models/migrations/v1_27/v331_test.go @@ -0,0 +1,156 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_27 + +import ( + "context" + "slices" + "testing" + + "code.gitea.io/gitea/models/migrations/base" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/xorm/schemas" +) + +type actionRunBeforeV331 struct { + ID int64 `xorm:"pk autoincr"` + ConcurrencyGroup string + ConcurrencyCancel bool + LatestAttemptID int64 `xorm:"-"` +} + +func (actionRunBeforeV331) TableName() string { + return "action_run" +} + +type actionRunJobBeforeV331 struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index"` + RepoID int64 `xorm:"index"` +} + +func (actionRunJobBeforeV331) TableName() string { + return "action_run_job" +} + +type actionArtifactBeforeV331 struct { + ID int64 `xorm:"pk autoincr"` + RunID int64 `xorm:"index unique(runid_name_path)"` + RepoID int64 `xorm:"index"` + ArtifactPath string `xorm:"index unique(runid_name_path)"` + ArtifactName string `xorm:"index unique(runid_name_path)"` +} + +func (actionArtifactBeforeV331) TableName() string { + return "action_artifact" +} + +func Test_AddActionRunAttemptModel(t *testing.T) { + x, deferable := base.PrepareTestEnv(t, 0, + new(actionRunBeforeV331), + new(actionRunJobBeforeV331), + new(actionArtifactBeforeV331), + ) + defer deferable() + if x == nil || t.Failed() { + return + } + + _, err := x.Insert(&actionArtifactBeforeV331{ + RunID: 1, + RepoID: 1, + ArtifactPath: "artifact/path", + ArtifactName: "artifact-name", + }) + require.NoError(t, err) + + require.NoError(t, AddActionRunAttemptModel(x)) + + tableMap := base.LoadTableSchemasMap(t, x) + + attemptTable := tableMap["action_run_attempt"] + require.NotNil(t, attemptTable) + attemptTablCols := []string{"id", "repo_id", "run_id", "attempt", "trigger_user_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "created", "updated"} + require.ElementsMatch(t, attemptTable.ColumnsSeq(), attemptTablCols) + + runTable := tableMap["action_run"] + require.NotNil(t, runTable) + require.Contains(t, runTable.ColumnsSeq(), "latest_attempt_id") + require.NotContains(t, runTable.ColumnsSeq(), "concurrency_group") + require.NotContains(t, runTable.ColumnsSeq(), "concurrency_cancel") + + jobTable := tableMap["action_run_job"] + require.NotNil(t, jobTable) + require.Contains(t, jobTable.ColumnsSeq(), "run_attempt_id") + require.Contains(t, jobTable.ColumnsSeq(), "attempt_job_id") + require.Contains(t, jobTable.ColumnsSeq(), "source_task_id") + + attemptIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run_attempt") + require.NoError(t, err) + assert.True(t, hasIndexWithColumns(attemptIndexes, []string{"run_id", "attempt"}, true)) + assert.True(t, hasIndexWithColumns(attemptIndexes, []string{"repo_id", "concurrency_group", "status"}, false)) + + runIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run") + require.NoError(t, err) + assert.True(t, hasIndexWithColumns(runIndexes, []string{"latest_attempt_id"}, false)) + assert.False(t, hasIndexWithColumns(runIndexes, []string{"repo_id", "concurrency_group"}, false)) + + jobIndexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_run_job") + require.NoError(t, err) + assert.True(t, hasIndexWithColumns(jobIndexes, []string{"run_attempt_id"}, false)) + assert.True(t, hasIndexWithColumns(jobIndexes, []string{"attempt_job_id"}, false)) + + indexes, err := x.Dialect().GetIndexes(x.DB(), context.Background(), "action_artifact") + require.NoError(t, err) + assert.False(t, hasIndexWithColumns(indexes, []string{"run_id", "artifact_path", "artifact_name"}, true)) + assert.True(t, hasIndexWithColumns(indexes, []string{"run_id", "run_attempt_id", "artifact_path", "artifact_name"}, true)) + + _, err = x.Insert(&actionArtifact{ + RunID: 1, + RunAttemptID: 2, + RepoID: 1, + ArtifactPath: "artifact/path", + ArtifactName: "artifact-name", + }) + require.NoError(t, err) + _, err = x.Insert(&actionArtifact{ + RunID: 1, + RunAttemptID: 2, + RepoID: 1, + ArtifactPath: "artifact/path", + ArtifactName: "artifact-name", + }) + require.Error(t, err) + + _, err = x.Insert(&actionRunAttempt{ + RepoID: 1, + RunID: 1, + Attempt: 2, + TriggerUserID: 1, + Status: 1, + }) + require.NoError(t, err) + _, err = x.Insert(&actionRunAttempt{ + RepoID: 1, + RunID: 1, + Attempt: 2, + TriggerUserID: 2, + Status: 1, + }) + require.Error(t, err) +} + +func hasIndexWithColumns(indexes map[string]*schemas.Index, cols []string, isUnique bool) bool { + for _, index := range indexes { + if isUnique && index.Type != schemas.UniqueType { + continue + } + if slices.Equal(index.Cols, cols) { + return true + } + } + return false +} diff --git a/models/migrations/v1_6/v71.go b/models/migrations/v1_6/v71.go index 2b11f57c92f..b4dcd87ebae 100644 --- a/models/migrations/v1_6/v71.go +++ b/models/migrations/v1_6/v71.go @@ -51,10 +51,7 @@ func AddScratchHash(x *xorm.Engine) error { for _, tfa := range tfas { // generate salt - salt, err := util.CryptoRandomString(10) - if err != nil { - return err - } + salt := util.CryptoRandomString(10) tfa.ScratchSalt = salt tfa.ScratchHash = base.HashToken(tfa.ScratchToken, salt) diff --git a/models/migrations/v1_9/v85.go b/models/migrations/v1_9/v85.go index 48e1cd5dc4e..0e95a71f929 100644 --- a/models/migrations/v1_9/v85.go +++ b/models/migrations/v1_9/v85.go @@ -65,10 +65,7 @@ func HashAppToken(x *xorm.Engine) error { for _, token := range tokens { // generate salt - salt, err := util.CryptoRandomString(10) - if err != nil { - return err - } + salt := util.CryptoRandomString(10) token.TokenSalt = salt token.TokenHash = base.HashToken(token.Sha1, salt) if len(token.Sha1) < 8 { diff --git a/models/organization/org_user.go b/models/organization/org_user.go index 69cd9609446..627c1c2edf0 100644 --- a/models/organization/org_user.go +++ b/models/organization/org_user.go @@ -8,10 +8,7 @@ import ( "fmt" "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/models/perm" - "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "xorm.io/builder" @@ -129,49 +126,6 @@ func IsUserOrgOwner(ctx context.Context, users user_model.UserList, orgID int64) return results } -// GetOrgAssignees returns all users that have write access and can be assigned to issues -// of the any repository in the organization. -func GetOrgAssignees(ctx context.Context, orgID int64) (_ []*user_model.User, err error) { - e := db.GetEngine(ctx) - userIDs := make([]int64, 0, 10) - if err = e.Table("access"). - Join("INNER", "repository", "`repository`.id = `access`.repo_id"). - Where("`repository`.owner_id = ? AND `access`.mode >= ?", orgID, perm.AccessModeWrite). - Select("user_id"). - Find(&userIDs); err != nil { - return nil, err - } - - additionalUserIDs := make([]int64, 0, 10) - if err = e.Table("team_user"). - Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id"). - Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id"). - Join("INNER", "repository", "`repository`.id = `team_repo`.repo_id"). - Where("`repository`.owner_id = ? AND (`team_unit`.access_mode >= ? OR (`team_unit`.access_mode = ? AND `team_unit`.`type` = ?))", - orgID, perm.AccessModeWrite, perm.AccessModeRead, unit.TypePullRequests). - Distinct("`team_user`.uid"). - Select("`team_user`.uid"). - Find(&additionalUserIDs); err != nil { - return nil, err - } - - uniqueUserIDs := make(container.Set[int64]) - uniqueUserIDs.AddMultiple(userIDs...) - uniqueUserIDs.AddMultiple(additionalUserIDs...) - - users := make([]*user_model.User, 0, len(uniqueUserIDs)) - if len(userIDs) > 0 { - if err = e.In("id", uniqueUserIDs.Values()). - Where(builder.Eq{"`user`.is_active": true}). - OrderBy(user_model.GetOrderByName()). - Find(&users); err != nil { - return nil, err - } - } - - return users, nil -} - func loadOrganizationOwners(ctx context.Context, users user_model.UserList, orgID int64) (map[int64]*TeamUser, error) { if len(users) == 0 { return nil, nil //nolint:nilnil // return nil when there are no users diff --git a/models/organization/team_invite.go b/models/organization/team_invite.go index 17f6c596104..186ae5f6e82 100644 --- a/models/organization/team_invite.go +++ b/models/organization/team_invite.go @@ -116,10 +116,7 @@ func CreateTeamInvite(ctx context.Context, doer *user_model.User, team *Team, em } } - token, err := util.CryptoRandomString(25) - if err != nil { - return nil, err - } + token := util.CryptoRandomString(25) invite := &TeamInvite{ Token: token, diff --git a/models/organization/team_list.go b/models/organization/team_list.go index 0274f9c5ba4..5629cec3661 100644 --- a/models/organization/team_list.go +++ b/models/organization/team_list.go @@ -88,7 +88,7 @@ func SearchTeam(ctx context.Context, opts *SearchTeamOptions) (TeamList, int64, sess = db.SetSessionPagination(sess, opts) teams := make([]*Team, 0, opts.PageSize) - count, err := sess.Where(cond).OrderBy("lower_name").FindAndCount(&teams) + count, err := sess.Where(cond).OrderBy("CASE WHEN name=? THEN '' ELSE lower_name END", OwnerTeamName).FindAndCount(&teams) if err != nil { return nil, 0, err } diff --git a/models/organization/team_unit.go b/models/organization/team_unit.go index c6ec6b39b2c..b5237c2c587 100644 --- a/models/organization/team_unit.go +++ b/models/organization/team_unit.go @@ -28,19 +28,3 @@ func (t *TeamUnit) Unit() unit.Unit { func getUnitsByTeamID(ctx context.Context, teamID int64) (units []*TeamUnit, err error) { return units, db.GetEngine(ctx).Where("team_id = ?", teamID).Find(&units) } - -// UpdateTeamUnits updates a teams's units -func UpdateTeamUnits(ctx context.Context, team *Team, units []TeamUnit) (err error) { - return db.WithTx(ctx, func(ctx context.Context) error { - if _, err = db.GetEngine(ctx).Where("team_id = ?", team.ID).Delete(new(TeamUnit)); err != nil { - return err - } - - if len(units) > 0 { - if err = db.Insert(ctx, units); err != nil { - return err - } - } - return nil - }) -} diff --git a/models/organization/team_user.go b/models/organization/team_user.go index d6d0a5054dd..d24a4c51263 100644 --- a/models/organization/team_user.go +++ b/models/organization/team_user.go @@ -36,14 +36,6 @@ type SearchMembersOptions struct { TeamID int64 } -func (opts SearchMembersOptions) ToConds() builder.Cond { - cond := builder.NewCond() - if opts.TeamID > 0 { - cond = cond.And(builder.Eq{"": opts.TeamID}) - } - return cond -} - // GetTeamMembers returns all members in given team of organization. func GetTeamMembers(ctx context.Context, opts *SearchMembersOptions) ([]*user_model.User, error) { var members []*user_model.User diff --git a/models/packages/package_blob_upload.go b/models/packages/package_blob_upload.go index 4b0e789221b..60a55805a8b 100644 --- a/models/packages/package_blob_upload.go +++ b/models/packages/package_blob_upload.go @@ -31,16 +31,13 @@ type PackageBlobUpload struct { // CreateBlobUpload inserts a blob upload func CreateBlobUpload(ctx context.Context) (*PackageBlobUpload, error) { - id, err := util.CryptoRandomString(25) - if err != nil { - return nil, err - } + id := util.CryptoRandomString(25) pbu := &PackageBlobUpload{ ID: strings.ToLower(id), } - _, err = db.GetEngine(ctx).Insert(pbu) + _, err := db.GetEngine(ctx).Insert(pbu) return pbu, err } diff --git a/models/packages/package_property.go b/models/packages/package_property.go index c297fd89014..30794ad73c3 100644 --- a/models/packages/package_property.go +++ b/models/packages/package_property.go @@ -52,13 +52,13 @@ func InsertProperty(ctx context.Context, refType PropertyType, refID int64, name // GetProperties gets all properties func GetProperties(ctx context.Context, refType PropertyType, refID int64) ([]*PackageProperty, error) { pps := make([]*PackageProperty, 0, 10) - return pps, db.GetEngine(ctx).Where("ref_type = ? AND ref_id = ?", refType, refID).Find(&pps) + return pps, db.GetEngine(ctx).Where("ref_type = ? AND ref_id = ?", refType, refID).OrderBy("id").Find(&pps) } // GetPropertiesByName gets all properties with a specific name func GetPropertiesByName(ctx context.Context, refType PropertyType, refID int64, name string) ([]*PackageProperty, error) { pps := make([]*PackageProperty, 0, 10) - return pps, db.GetEngine(ctx).Where("ref_type = ? AND ref_id = ? AND name = ?", refType, refID, name).Find(&pps) + return pps, db.GetEngine(ctx).Where("ref_type = ? AND ref_id = ? AND name = ?", refType, refID, name).OrderBy("id").Find(&pps) } // UpdateProperty updates a property diff --git a/models/project/column.go b/models/project/column.go index 7365204f18e..9c9abb4599d 100644 --- a/models/project/column.go +++ b/models/project/column.go @@ -185,7 +185,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error { return err } - if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil { + if err = moveIssuesToAnotherColumn(ctx, column, defaultColumn); err != nil { return err } @@ -337,20 +337,6 @@ func SetDefaultColumn(ctx context.Context, projectID, columnID int64) error { }) } -// UpdateColumnSorting update project column sorting -func UpdateColumnSorting(ctx context.Context, cl ColumnList) error { - return db.WithTx(ctx, func(ctx context.Context) error { - for i := range cl { - if _, err := db.GetEngine(ctx).ID(cl[i].ID).Cols( - "sorting", - ).Update(cl[i]); err != nil { - return err - } - } - return nil - }) -} - func GetColumnsByIDs(ctx context.Context, projectID int64, columnsIDs []int64) (ColumnList, error) { columns := make([]*Column, 0, 5) if len(columnsIDs) == 0 { diff --git a/models/project/column_test.go b/models/project/column_test.go index 948e012c62d..6437a764ed3 100644 --- a/models/project/column_test.go +++ b/models/project/column_test.go @@ -59,7 +59,7 @@ func Test_moveIssuesToAnotherColumn(t *testing.T) { assert.Len(t, issues, 1) assert.EqualValues(t, 3, issues[0].ID) - err = column1.moveIssuesToAnotherColumn(t.Context(), column2) + err = moveIssuesToAnotherColumn(t.Context(), column1, column2) assert.NoError(t, err) issues, err = column1.GetIssues(t.Context()) diff --git a/models/project/issue.go b/models/project/issue.go index 47d1537ec73..c89f5243054 100644 --- a/models/project/issue.go +++ b/models/project/issue.go @@ -33,38 +33,45 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error return err } -func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error { - if c.ProjectID != newColumn.ProjectID { - return errors.New("columns have to be in the same project") - } - - if c.ID == newColumn.ID { - return nil - } - +// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column. +func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) { res := struct { MaxSorting int64 IssueCount int64 }{} - if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count"). + if _, err := db.GetEngine(ctx).Select("max(sorting) AS max_sorting, count(*) AS issue_count"). Table("project_issue"). - Where("project_id=?", newColumn.ProjectID). - And("project_board_id=?", newColumn.ID). + Where("project_id=?", projectID). + And("project_board_id=?", columnID). Get(&res); err != nil { - return err + return 0, err + } + return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil +} + +func moveIssuesToAnotherColumn(ctx context.Context, oldColumn, newColumn *Column) error { + if oldColumn.ProjectID != newColumn.ProjectID { + return errors.New("columns have to be in the same project") } - issues, err := c.GetIssues(ctx) - if err != nil { - return err - } - if len(issues) == 0 { + if oldColumn.ID == newColumn.ID { return nil } - nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0) + movedIssues, err := oldColumn.GetIssues(ctx) + if err != nil { + return err + } + if len(movedIssues) == 0 { + return nil + } + + nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn.ProjectID, newColumn.ID) + if err != nil { + return err + } return db.WithTx(ctx, func(ctx context.Context) error { - for i, issue := range issues { + for i, issue := range movedIssues { issue.ProjectColumnID = newColumn.ID issue.Sorting = nextSorting + int64(i) if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil { diff --git a/models/pull/automerge.go b/models/pull/automerge.go index 7f940a98492..d32dc847d2d 100644 --- a/models/pull/automerge.go +++ b/models/pull/automerge.go @@ -5,14 +5,12 @@ package pull import ( "context" - "errors" "fmt" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" ) // AutoMerge represents a pull request scheduled for merging when checks succeed @@ -78,16 +76,8 @@ func GetScheduledMergeByPullID(ctx context.Context, pullID int64) (bool, *AutoMe return false, nil, err } - doer, err := user_model.GetPossibleUserByID(ctx, scheduledPRM.DoerID) - if errors.Is(err, util.ErrNotExist) { - doer, err = user_model.NewGhostUser(), nil - } - if err != nil { - return false, nil, err - } - - scheduledPRM.Doer = doer - return true, scheduledPRM, nil + scheduledPRM.DoerID, scheduledPRM.Doer, err = user_model.GetPossibleUserByID(ctx, scheduledPRM.DoerID) + return true, scheduledPRM, err } // DeleteScheduledAutoMerge delete a scheduled pull request diff --git a/models/renderhelper/repo_file.go b/models/renderhelper/repo_file.go index f1df8e89e0e..5d0bfd6c80f 100644 --- a/models/renderhelper/repo_file.go +++ b/models/renderhelper/repo_file.go @@ -50,8 +50,8 @@ type RepoFileOptions struct { DeprecatedRepoName string // it is only a patch for the non-standard "markup" api DeprecatedOwnerName string // it is only a patch for the non-standard "markup" api - CurrentRefPath string // eg: "branch/main" - CurrentTreePath string // eg: "path/to/file" in the repo + CurrentRefPath string // eg: "branch/main", it is a sub URL path escaped by callers, TODO: rename to CurrentRefSubURL + CurrentTreePath string // eg: "path/to/file" in the repo, it is the tree path without URL path escaping } func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, opts ...RepoFileOptions) *markup.RenderContext { @@ -70,6 +70,10 @@ func NewRenderContextRepoFile(ctx context.Context, repo *repo_model.Repository, "repo": helper.opts.DeprecatedRepoName, }) } + // External render's iframe needs this to generate correct links + // TODO: maybe need to make it access "CurrentRefPath" directly (but impossible at the moment due to cycle-import) + // CurrentRefPath is already path-escaped by callers + rctx.RenderOptions.Metas["RefTypeNameSubURL"] = helper.opts.CurrentRefPath rctx = rctx.WithHelper(helper).WithEnableHeadingIDGeneration(true) return rctx } diff --git a/models/repo.go b/models/repo.go index c5c5364da01..34e7b108033 100644 --- a/models/repo.go +++ b/models/repo.go @@ -9,8 +9,6 @@ import ( "strconv" "strings" - _ "image/jpeg" // Needed for jpeg support - "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" @@ -18,6 +16,8 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/log" + _ "image/jpeg" // Needed for jpeg support + "xorm.io/builder" ) diff --git a/models/repo/star.go b/models/repo/star.go index bc865f8373f..e1672623c82 100644 --- a/models/repo/star.go +++ b/models/repo/star.go @@ -11,7 +11,7 @@ import ( "code.gitea.io/gitea/modules/timeutil" ) -// Star represents a starred repo by an user. +// Star represents a starred repo by a user. type Star struct { ID int64 `xorm:"pk autoincr"` UID int64 `xorm:"UNIQUE(s)"` diff --git a/models/repo/topic.go b/models/repo/topic.go index 6d5209d8210..14017da2d0b 100644 --- a/models/repo/topic.go +++ b/models/repo/topic.go @@ -44,12 +44,6 @@ type ErrTopicNotExist struct { Name string } -// IsErrTopicNotExist checks if an error is an ErrTopicNotExist. -func IsErrTopicNotExist(err error) bool { - _, ok := err.(ErrTopicNotExist) - return ok -} - // Error implements error interface func (err ErrTopicNotExist) Error() string { return fmt.Sprintf("topic is not exist [name: %s]", err.Name) diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index e15a64b01e8..28ae83a095e 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -8,6 +8,7 @@ import ( "strings" "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" @@ -94,8 +95,7 @@ func GetWatchedRepos(ctx context.Context, opts *WatchedReposOptions) ([]*Reposit return db.FindAndCount[Repository](ctx, opts) } -// GetRepoAssignees returns all users that have write access and can be assigned to issues -// of the repository, +// GetRepoAssignees returns all users that have write access and can be assigned to issues or pull-requests of the repository, func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.User, err error) { if err = repo.LoadOwner(ctx); err != nil { return nil, err @@ -114,15 +114,9 @@ func GetRepoAssignees(ctx context.Context, repo *Repository) (_ []*user_model.Us uniqueUserIDs.AddMultiple(userIDs...) if repo.Owner.IsOrganization() { - additionalUserIDs := make([]int64, 0, 10) - if err = e.Table("team_user"). - Join("INNER", "team_repo", "`team_repo`.team_id = `team_user`.team_id"). - Join("INNER", "team_unit", "`team_unit`.team_id = `team_user`.team_id"). - Where("`team_repo`.repo_id = ? AND (`team_unit`.access_mode >= ? OR (`team_unit`.access_mode = ? AND `team_unit`.`type` = ?))", - repo.ID, perm.AccessModeWrite, perm.AccessModeRead, unit.TypePullRequests). - Distinct("`team_user`.uid"). - Select("`team_user`.uid"). - Find(&additionalUserIDs); err != nil { + // issues and pull requests both need "assignee list" + additionalUserIDs, err := organization.GetTeamUserIDsWithAccessToAnyRepoUnit(ctx, repo.OwnerID, repo.ID, perm.AccessModeRead, unit.TypeIssues, unit.TypePullRequests) + if err != nil { return nil, err } uniqueUserIDs.AddMultiple(additionalUserIDs...) diff --git a/models/repo/user_repo_test.go b/models/repo/user_repo_test.go index cd8a0f1a1f7..cd45db61d08 100644 --- a/models/repo/user_repo_test.go +++ b/models/repo/user_repo_test.go @@ -6,7 +6,12 @@ package repo_test import ( "testing" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + perm_model "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -14,9 +19,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestRepoAssignees(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func TestUserRepo(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + t.Run("GetIssuePostersWithSearch", testUserRepoGetIssuePostersWithSearch) + t.Run("Assignees", testUserRepoAssignees) + t.Run("AssigneesNoTeamUnit", testRepoAssigneesNoTeamUnit) +} +func testUserRepoAssignees(t *testing.T) { repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) users, err := repo_model.GetRepoAssignees(t.Context(), repo2) assert.NoError(t, err) @@ -39,9 +49,29 @@ func TestRepoAssignees(t *testing.T) { } } -func TestGetIssuePostersWithSearch(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) +func testRepoAssigneesNoTeamUnit(t *testing.T) { + ctx := t.Context() + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32}) + require.NoError(t, repo.LoadOwner(ctx)) + require.True(t, repo.Owner.IsOrganization()) + + require.NoError(t, db.TruncateBeans(ctx, &organization.Team{}, &organization.TeamUser{}, &organization.TeamRepo{}, &organization.TeamUnit{}, &access_model.Access{})) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + team := &organization.Team{OrgID: repo.OwnerID, LowerName: "admin-team", AccessMode: perm_model.AccessModeAdmin} + require.NoError(t, db.Insert(ctx, team)) + require.NoError(t, db.Insert(ctx, &organization.TeamUser{OrgID: repo.OwnerID, TeamID: team.ID, UID: user.ID})) + require.NoError(t, db.Insert(ctx, &organization.TeamRepo{OrgID: repo.OwnerID, TeamID: team.ID, RepoID: repo.ID})) + require.NoError(t, db.Insert(ctx, &organization.TeamUnit{OrgID: repo.OwnerID, TeamID: team.ID, Type: unit.TypePullRequests, AccessMode: perm_model.AccessModeNone})) + + users, err := repo_model.GetRepoAssignees(ctx, repo) + require.NoError(t, err) + require.Len(t, users, 1) + assert.ElementsMatch(t, []int64{4}, []int64{users[0].ID}) +} + +func testUserRepoGetIssuePostersWithSearch(t *testing.T) { repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) users, err := repo_model.GetIssuePostersWithSearch(t.Context(), repo2, false, "USER") diff --git a/models/system/notice.go b/models/system/notice.go index f39188f8fb3..4b919dffc92 100644 --- a/models/system/notice.go +++ b/models/system/notice.go @@ -13,7 +13,6 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" ) // NoticeType describes the notice type @@ -60,18 +59,6 @@ func CreateRepositoryNotice(desc string, args ...any) error { return CreateNotice(graceful.GetManager().ShutdownContext(), NoticeRepository, desc, args...) } -// RemoveAllWithNotice removes all directories in given path and -// creates a system notice when error occurs. -func RemoveAllWithNotice(ctx context.Context, title, path string) { - if err := util.RemoveAll(path); err != nil { - desc := fmt.Sprintf("%s [%s]: %v", title, path, err) - log.Warn(title+" [%s]: %v", path, err) - if err = CreateNotice(graceful.GetManager().ShutdownContext(), NoticeRepository, desc); err != nil { - log.Error("CreateRepositoryNotice: %v", err) - } - } -} - // RemoveStorageWithNotice removes a file from the storage and // creates a system notice when error occurs. func RemoveStorageWithNotice(ctx context.Context, bucket storage.ObjectStorage, title, path string) { diff --git a/models/unittest/mock_http.go b/models/unittest/mock_http.go new file mode 100644 index 00000000000..dd05b263ede --- /dev/null +++ b/models/unittest/mock_http.go @@ -0,0 +1,142 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package unittest + +import ( + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "net/url" + "os" + "slices" + "strings" + "testing" + + "code.gitea.io/gitea/modules/log" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockServerOptions tweaks NewMockWebServer behavior. +type MockServerOptions struct { + // Routes installs extra handlers on the mux before the fixture fallback; + // more specific patterns win. + Routes func(mux *http.ServeMux) + // StripPrefix is trimmed from the request path before forwarding upstream, + // useful when the client prepends a prefix the real upstream does not use + // (e.g. go-github prepends "/api/v3"). + StripPrefix string +} + +// NewMockWebServer returns a test HTTP server that records upstream responses on demand +// and replays them from disk on subsequent runs. +// +// - liveMode=true: requests are forwarded to liveServerBaseURL and responses written as +// fixture files under testDataDir. +// - liveMode=false: responses come from existing fixture files. +// +// Fixture format: header lines ("Name: value"), a blank line, then the body. Before +// replay, occurrences of liveServerBaseURL in the body are swapped for the mock URL. +// +// The typical switch is an env var holding an API token; fixtures ship committed so the +// default run (no token) works offline. +// +// token := os.Getenv("GITEA_TOKEN") +// mock := NewMockWebServer(t, "https://gitea.com", fixtureDir, token != "") +func NewMockWebServer(t *testing.T, liveServerBaseURL, testDataDir string, liveMode bool, opts ...MockServerOptions) *httptest.Server { + t.Helper() + + var opt MockServerOptions + if len(opts) > 0 { + opt = opts[0] + } + + ignoredHeaders := []string{"cf-ray", "server", "date", "report-to", "nel", "x-request-id", "set-cookie"} + + var mockURL string + + fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reqPath := r.URL.EscapedPath() + if r.URL.RawQuery != "" { + reqPath += "?" + r.URL.RawQuery + } + log.Info("mock server: %s %s", r.Method, reqPath) + + fixturePath := fmt.Sprintf("%s/%s_%s", testDataDir, r.Method, url.QueryEscape(reqPath)) + if strings.Contains(r.URL.Path, ".git/") { + fixturePath = fmt.Sprintf("%s/%s_%s", testDataDir, r.Method, url.QueryEscape(r.URL.Path)) + } + + if liveMode { + require.NoError(t, os.MkdirAll(testDataDir, 0o755)) + + liveURL := liveServerBaseURL + strings.TrimPrefix(reqPath, opt.StripPrefix) + req, err := http.NewRequest(r.Method, liveURL, r.Body) + require.NoError(t, err, "building upstream request to %s", liveURL) + for name, values := range r.Header { + if strings.EqualFold(name, "accept-encoding") { + continue + } + for _, value := range values { + req.Header.Add(name, value) + } + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err, "upstream request to %s failed", liveURL) + defer resp.Body.Close() + assert.Less(t, resp.StatusCode, 400, "upstream %s returned status %d", liveURL, resp.StatusCode) + + out, err := os.Create(fixturePath) + require.NoError(t, err, "creating fixture %s", fixturePath) + defer out.Close() + + for _, name := range slices.Sorted(maps.Keys(resp.Header)) { + if slices.Contains(ignoredHeaders, strings.ToLower(name)) { + continue + } + for _, value := range resp.Header[name] { + _, err := fmt.Fprintf(out, "%s: %s\n", name, value) + require.NoError(t, err) + } + } + _, err = out.WriteString("\n") + require.NoError(t, err) + + _, err = io.Copy(out, resp.Body) + require.NoError(t, err, "writing fixture body for %s", liveURL) + require.NoError(t, out.Sync()) + } + + raw, err := os.ReadFile(fixturePath) + require.NoError(t, err, "missing fixture: %s", fixturePath) + + replayed := strings.ReplaceAll(string(raw), liveServerBaseURL, mockURL) + headers, body, _ := strings.Cut(replayed, "\n\n") + for line := range strings.SplitSeq(headers, "\n") { + name, value, ok := strings.Cut(line, ": ") + if !ok || strings.EqualFold(name, "Content-Length") { + continue + } + w.Header().Set(name, value) + } + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte(body)) + require.NoError(t, err) + }) + + mux := http.NewServeMux() + if opt.Routes != nil { + opt.Routes(mux) + } + mux.Handle("/", fallback) + + server := httptest.NewServer(mux) + mockURL = server.URL + t.Cleanup(server.Close) + return server +} diff --git a/models/unittest/testdb.go b/models/unittest/testdb.go index 63c9a3a9994..bd832348e7c 100644 --- a/models/unittest/testdb.go +++ b/models/unittest/testdb.go @@ -13,10 +13,8 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/system" - "code.gitea.io/gitea/modules/auth/password/hash" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting/config" "code.gitea.io/gitea/modules/storage" @@ -29,37 +27,6 @@ import ( "xorm.io/xorm/names" ) -// InitSettingsForTesting initializes config provider and load common settings for tests -func InitSettingsForTesting() { - setting.SetupGiteaTestEnv() - - log.OsExiter = func(code int) { - if code != 0 { - // non-zero exit code (log.Fatal) shouldn't occur during testing, if it happens, show a full stacktrace for more details - panic(fmt.Errorf("non-zero exit code during testing: %d", code)) - } - os.Exit(0) - } - if setting.CustomConf == "" { - setting.CustomConf = filepath.Join(setting.CustomPath, "conf/app-unittest-tmp.ini") - _ = os.Remove(setting.CustomConf) - } - - // init paths and config system for testing - getTestEnv := func(key string) string { - return "" - } - setting.InitWorkPathAndCommonConfig(getTestEnv, setting.ArgWorkPathAndCustomConf{CustomConf: setting.CustomConf}) - - if err := setting.PrepareAppDataPath(); err != nil { - log.Fatal("Can not prepare APP_DATA_PATH: %v", err) - } - // register the dummy hash algorithm function used in the test fixtures - _ = hash.Register("dummy", hash.NewDummyHasher) - - setting.PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy") -} - // TestOptions represents test options type TestOptions struct { FixtureFiles []string @@ -75,11 +42,20 @@ func MainTest(m *testing.M, testOptsArg ...*TestOptions) { func mainTest(m *testing.M, testOptsArg ...*TestOptions) int { testOpts := util.OptionalArg(testOptsArg, &TestOptions{}) - InitSettingsForTesting() + + tempWorkPath, tempCleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("unit-test-dir-") + if err != nil { + return testlogger.MainErrorf("Failed to create temp dir for unit test: %v", err) + } + defer tempCleanup() + + defer setting.MockBuiltinPaths(tempWorkPath, "", "")() + setting.SetupGiteaTestEnv() + giteaRoot := setting.GetGiteaTestSourceRoot() fixturesOpts := FixturesOptions{Dir: filepath.Join(giteaRoot, "models", "fixtures"), Files: testOpts.FixtureFiles} if err := CreateTestEngine(fixturesOpts); err != nil { - testlogger.Panicf("Error creating test engine: %v\n", err) + return testlogger.MainErrorf("Error creating test database engine: %v", err) } setting.AppURL = "https://try.gitea.io/" @@ -91,59 +67,28 @@ func mainTest(m *testing.M, testOptsArg ...*TestOptions) int { setting.SSH.Domain = "try.gitea.io" setting.Database.Type = "sqlite3" setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master" - repoRootPath, cleanup1, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("repos") - if err != nil { - testlogger.Panicf("TempDir: %v\n", err) - } - defer cleanup1() - - setting.RepoRootPath = repoRootPath - appDataPath, cleanup2, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("appdata") - if err != nil { - testlogger.Panicf("TempDir: %v\n", err) - } - defer cleanup2() - - setting.AppDataPath = appDataPath setting.GravatarSource = "https://secure.gravatar.com/avatar/" - - setting.Attachment.Storage.Path = filepath.Join(setting.AppDataPath, "attachments") - - setting.LFS.Storage.Path = filepath.Join(setting.AppDataPath, "lfs") - - setting.Avatar.Storage.Path = filepath.Join(setting.AppDataPath, "avatars") - - setting.RepoAvatar.Storage.Path = filepath.Join(setting.AppDataPath, "repo-avatars") - - setting.RepoArchive.Storage.Path = filepath.Join(setting.AppDataPath, "repo-archive") - - setting.Packages.Storage.Path = filepath.Join(setting.AppDataPath, "packages") - - setting.Actions.LogStorage.Path = filepath.Join(setting.AppDataPath, "actions_log") - - setting.Git.HomePath = filepath.Join(setting.AppDataPath, "home") - setting.IncomingEmail.ReplyToAddress = "incoming+%{token}@localhost" config.SetDynGetter(system.NewDatabaseDynKeyGetter()) if err = cache.Init(); err != nil { - testlogger.Panicf("cache.Init: %v\n", err) + return testlogger.MainErrorf("cache.Init: %v", err) } if err = storage.Init(); err != nil { - testlogger.Panicf("storage.Init: %v\n", err) + return testlogger.MainErrorf("storage.Init: %v", err) } if err = SyncDirs(filepath.Join(giteaRoot, "tests", "gitea-repositories-meta"), setting.RepoRootPath); err != nil { - testlogger.Panicf("util.SyncDirs: %v\n", err) + return testlogger.MainErrorf("util.SyncDirs: %v", err) } if err = git.InitFull(); err != nil { - testlogger.Panicf("git.Init: %v\n", err) + return testlogger.MainErrorf("git.Init: %v", err) } if testOpts.SetUp != nil { if err := testOpts.SetUp(); err != nil { - testlogger.Panicf("set up failed: %v\n", err) + return testlogger.MainErrorf("set up failed: %v", err) } } @@ -151,7 +96,7 @@ func mainTest(m *testing.M, testOptsArg ...*TestOptions) int { if testOpts.TearDown != nil { if err := testOpts.TearDown(); err != nil { - testlogger.Panicf("tear down failed: %v\n", err) + return testlogger.MainErrorf("tear down failed: %v", err) } } return exitStatus diff --git a/models/user/badge.go b/models/user/badge.go index fbba8659261..a4a465a9d54 100644 --- a/models/user/badge.go +++ b/models/user/badge.go @@ -64,7 +64,7 @@ type GetBadgeUsersOptions struct { func GetBadgeUsers(ctx context.Context, opts *GetBadgeUsersOptions) ([]*User, int64, error) { sess := db.GetEngine(ctx). Select("`user`.*"). - Join("INNER", "user_badge", "`user_badge`.user_id=user.id"). + Join("INNER", "user_badge", "`user_badge`.user_id=`user`.id"). Join("INNER", "badge", "`user_badge`.badge_id=badge.id"). Where("badge.slug=?", opts.BadgeSlug) @@ -212,12 +212,6 @@ func RemoveUserBadges(ctx context.Context, u *User, badges []*Badge) error { }) } -// RemoveAllUserBadges removes all badges from a user. -func RemoveAllUserBadges(ctx context.Context, u *User) error { - _, err := db.GetEngine(ctx).Where("user_id=?", u.ID).Delete(&UserBadge{}) - return err -} - // SearchBadgeOptions represents the options when finding badges type SearchBadgeOptions struct { db.ListOptions @@ -258,16 +252,3 @@ func (opts *SearchBadgeOptions) ToOrders() string { func SearchBadges(ctx context.Context, opts *SearchBadgeOptions) ([]*Badge, int64, error) { return db.FindAndCount[Badge](ctx, opts) } - -// GetBadgeByID returns a specific badge by ID -func GetBadgeByID(ctx context.Context, id int64) (*Badge, error) { - badge := new(Badge) - has, err := db.GetEngine(ctx).ID(id).Get(badge) - if err != nil { - return nil, err - } - if !has { - return nil, util.NewNotExistErrorf("badge does not exist [id: %d]", id) - } - return badge, nil -} diff --git a/models/user/block.go b/models/user/block.go index f4afd47d0f7..03f984a8fdf 100644 --- a/models/user/block.go +++ b/models/user/block.go @@ -90,7 +90,7 @@ func GetBlocking(ctx context.Context, blockerID, blockeeID int64) (*Blocking, er return nil, err } if len(blocks) == 0 { - return nil, nil //nolint:nilnil // return nil to indicate that the object does not exist + return nil, util.NewNotExistErrorf("blocking record doesn't exist") } return blocks[0], nil } diff --git a/models/user/email_address.go b/models/user/email_address.go index aa483d5f005..670d417f9e6 100644 --- a/models/user/email_address.go +++ b/models/user/email_address.go @@ -147,11 +147,6 @@ func InsertEmailAddress(ctx context.Context, email *EmailAddress) (*EmailAddress return email, nil } -func UpdateEmailAddress(ctx context.Context, email *EmailAddress) error { - _, err := db.GetEngine(ctx).ID(email.ID).AllCols().Update(email) - return err -} - // ValidateEmail check if email is a valid & allowed address func ValidateEmail(email string) error { if err := validateEmailBasic(email); err != nil { diff --git a/models/user/error.go b/models/user/error.go index cbf19998d10..5a956a2afe1 100644 --- a/models/user/error.go +++ b/models/user/error.go @@ -71,27 +71,6 @@ func (err ErrUserProhibitLogin) Unwrap() error { return util.ErrPermissionDenied } -// ErrUserInactive represents a "ErrUserInactive" kind of error. -type ErrUserInactive struct { - UID int64 - Name string -} - -// IsErrUserInactive checks if an error is a ErrUserInactive -func IsErrUserInactive(err error) bool { - _, ok := err.(ErrUserInactive) - return ok -} - -func (err ErrUserInactive) Error() string { - return fmt.Sprintf("user is inactive [uid: %d, name: %s]", err.UID, err.Name) -} - -// Unwrap unwraps this error as a ErrPermission error -func (err ErrUserInactive) Unwrap() error { - return util.ErrPermissionDenied -} - // ErrUserIsNotLocal represents a "ErrUserIsNotLocal" kind of error. type ErrUserIsNotLocal struct { UID int64 diff --git a/models/user/external_login_user.go b/models/user/external_login_user.go index 0e764efb9fe..636a2007ee8 100644 --- a/models/user/external_login_user.go +++ b/models/user/external_login_user.go @@ -21,12 +21,6 @@ type ErrExternalLoginUserAlreadyExist struct { LoginSourceID int64 } -// IsErrExternalLoginUserAlreadyExist checks if an error is a ExternalLoginUserAlreadyExist. -func IsErrExternalLoginUserAlreadyExist(err error) bool { - _, ok := err.(ErrExternalLoginUserAlreadyExist) - return ok -} - func (err ErrExternalLoginUserAlreadyExist) Error() string { return fmt.Sprintf("external login user already exists [externalID: %s, userID: %d, loginSourceID: %d]", err.ExternalID, err.UserID, err.LoginSourceID) } @@ -41,12 +35,6 @@ type ErrExternalLoginUserNotExist struct { LoginSourceID int64 } -// IsErrExternalLoginUserNotExist checks if an error is a ExternalLoginUserNotExist. -func IsErrExternalLoginUserNotExist(err error) bool { - _, ok := err.(ErrExternalLoginUserNotExist) - return ok -} - func (err ErrExternalLoginUserNotExist) Error() string { return fmt.Sprintf("external login user link does not exists [userID: %d, loginSourceID: %d]", err.UserID, err.LoginSourceID) } diff --git a/models/user/setting.go b/models/user/setting.go index a16fc86e55e..67b45208fcf 100644 --- a/models/user/setting.go +++ b/models/user/setting.go @@ -50,12 +50,6 @@ func (err ErrUserSettingIsNotExist) Unwrap() error { return util.ErrNotExist } -// IsErrUserSettingIsNotExist return true if err is ErrSettingIsNotExist -func IsErrUserSettingIsNotExist(err error) bool { - _, ok := err.(ErrUserSettingIsNotExist) - return ok -} - // genSettingCacheKey returns the cache key for some configuration func genSettingCacheKey(userID int64, key string) string { return fmt.Sprintf("user_%d.setting.%s", userID, key) diff --git a/models/user/user.go b/models/user/user.go index 41cf89ad309..69b97e9b478 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -7,6 +7,7 @@ package user import ( "context" "encoding/hex" + "errors" "fmt" "html/template" "mime" @@ -20,8 +21,6 @@ import ( "time" "unicode" - _ "image/jpeg" // Needed for jpeg support - "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/auth/openid" @@ -39,6 +38,8 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/validation" + _ "image/jpeg" // Needed for jpeg support + "golang.org/x/text/runes" "golang.org/x/text/transform" "golang.org/x/text/unicode/norm" @@ -524,10 +525,7 @@ const SaltByteLength = 16 // GetUserSalt returns a random user salt token. func GetUserSalt() (string, error) { - rBytes, err := util.CryptoRandomBytes(SaltByteLength) - if err != nil { - return "", err - } + rBytes := util.CryptoRandomBytes(SaltByteLength) // Returns a 32-byte long string. return hex.EncodeToString(rBytes), nil } @@ -1016,17 +1014,22 @@ func GetUserByIDs(ctx context.Context, ids []int64) ([]*User, error) { return users, err } -// GetPossibleUserByID returns the user if id > 0 or returns system user if id < 0 -func GetPossibleUserByID(ctx context.Context, id int64) (*User, error) { +// GetPossibleUserByID returns the possible user and its ID. If the user doesn't exist, it returns Ghost user +func GetPossibleUserByID(ctx context.Context, id int64) (_ int64, u *User, err error) { if id < 0 { if newFunc, ok := globalVars().systemUserNewFuncs[id]; ok { - return newFunc(), nil + u = newFunc() } - return nil, ErrUserNotExist{UID: id} - } else if id == 0 { - return nil, ErrUserNotExist{} } - return GetUserByID(ctx, id) + if u == nil { + u, err = GetUserByID(ctx, id) + if errors.Is(err, util.ErrNotExist) { + u = NewGhostUser() + } else if err != nil { + return 0, nil, err + } + } + return u.ID, u, nil } // GetPossibleUserByIDs returns the users if id > 0 or returns system users if id < 0 @@ -1462,16 +1465,6 @@ func IsUserVisibleToViewer(ctx context.Context, u, viewer *User) bool { return false } -// CountWrongUserType count OrgUser who have wrong type -func CountWrongUserType(ctx context.Context) (int64, error) { - return db.GetEngine(ctx).Where(builder.Eq{"type": 0}.And(builder.Neq{"num_teams": 0})).Count(new(User)) -} - -// FixWrongUserType fix OrgUser who have wrong type -func FixWrongUserType(ctx context.Context) (int64, error) { - return db.GetEngine(ctx).Where(builder.Eq{"type": 0}.And(builder.Neq{"num_teams": 0})).Cols("type").NoAutoTime().Update(&User{Type: 1}) -} - func GetOrderByName() string { if setting.UI.DefaultShowFullName { return "full_name, name" diff --git a/models/user/user_system_test.go b/models/user/user_system_test.go index 70a900378f3..3ae9c6e3665 100644 --- a/models/user/user_system_test.go +++ b/models/user/user_system_test.go @@ -11,8 +11,9 @@ import ( ) func TestSystemUser(t *testing.T) { - u, err := GetPossibleUserByID(t.Context(), -1) + uid, u, err := GetPossibleUserByID(t.Context(), -1) require.NoError(t, err) + assert.Equal(t, int64(-1), uid) assert.Equal(t, "Ghost", u.Name) assert.Equal(t, "ghost", u.LowerName) assert.True(t, u.IsGhost()) @@ -21,8 +22,9 @@ func TestSystemUser(t *testing.T) { require.NotNil(t, u) assert.Equal(t, "Ghost", u.Name) - u, err = GetPossibleUserByID(t.Context(), -2) + uid, u, err = GetPossibleUserByID(t.Context(), -2) require.NoError(t, err) + assert.Equal(t, int64(-2), uid) assert.Equal(t, "gitea-actions", u.Name) assert.Equal(t, "gitea-actions", u.LowerName) assert.True(t, u.IsGiteaActions()) @@ -31,6 +33,8 @@ func TestSystemUser(t *testing.T) { require.NotNil(t, u) assert.Equal(t, "Gitea Actions", u.FullName) - _, err = GetPossibleUserByID(t.Context(), -3) - require.Error(t, err) + uid, u, err = GetPossibleUserByID(t.Context(), 999999) + require.NoError(t, err) + assert.Equal(t, int64(-1), uid) + assert.Equal(t, "Ghost", u.Name) } diff --git a/models/webhook/main_test.go b/models/webhook/main_test.go index f19465d5053..5f2d5081a1a 100644 --- a/models/webhook/main_test.go +++ b/models/webhook/main_test.go @@ -15,5 +15,6 @@ func TestMain(m *testing.M) { "webhook.yml", "hook_task.yml", }, + SetUp: prepareWebhookTestData, }) } diff --git a/models/webhook/webhook_system_test.go b/models/webhook/webhook_system_test.go index d0013c6873f..9e954d1e377 100644 --- a/models/webhook/webhook_system_test.go +++ b/models/webhook/webhook_system_test.go @@ -10,28 +10,28 @@ import ( "code.gitea.io/gitea/modules/optional" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestListSystemWebhookOptions(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hookSystem := unittest.AssertExistsAndLoadBean(t, &Webhook{URL: "https://www.example.com/system"}) + hookDefault := unittest.AssertExistsAndLoadBean(t, &Webhook{URL: "https://www.example.com/default"}) opts := ListSystemWebhookOptions{IsSystem: optional.None[bool]()} hooks, _, err := GetGlobalWebhooks(t.Context(), &opts) - assert.NoError(t, err) - if assert.Len(t, hooks, 2) { - assert.Equal(t, int64(5), hooks[0].ID) - assert.Equal(t, int64(6), hooks[1].ID) - } + require.NoError(t, err) + require.Len(t, hooks, 2) + assert.Equal(t, hookSystem.ID, hooks[0].ID) + assert.Equal(t, hookDefault.ID, hooks[1].ID) + opts.IsSystem = optional.Some(true) hooks, _, err = GetGlobalWebhooks(t.Context(), &opts) - assert.NoError(t, err) - if assert.Len(t, hooks, 1) { - assert.Equal(t, int64(5), hooks[0].ID) - } + require.NoError(t, err) + require.Len(t, hooks, 1) + assert.Equal(t, hookSystem.ID, hooks[0].ID) opts.IsSystem = optional.Some(false) hooks, _, err = GetGlobalWebhooks(t.Context(), &opts) - assert.NoError(t, err) - if assert.Len(t, hooks, 1) { - assert.Equal(t, int64(6), hooks[0].ID) - } + require.NoError(t, err) + require.Len(t, hooks, 1) + assert.Equal(t, hookDefault.ID, hooks[0].ID) } diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index 71f50017c51..073af91de21 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -4,6 +4,7 @@ package webhook import ( + "context" "testing" "time" @@ -14,40 +15,105 @@ import ( "code.gitea.io/gitea/modules/timeutil" webhook_module "code.gitea.io/gitea/modules/webhook" + "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "xorm.io/builder" ) -func TestHookContentType_Name(t *testing.T) { - assert.Equal(t, "json", ContentTypeJSON.Name()) - assert.Equal(t, "form", ContentTypeForm.Name()) +func prepareWebhookTestData() error { + if err := unittest.PrepareTestDatabase(); err != nil { + return err + } + var hooks []*Webhook + hooks = append(hooks, &Webhook{ + RepoID: 1, + URL: "https://www.example.com/url1", + ContentType: ContentTypeJSON, + Events: `{"push_only":true,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":false}}`, + IsActive: true, + }) + hooks = append(hooks, &Webhook{ + RepoID: 1, + URL: "https://www.example.com/url2", + ContentType: ContentTypeJSON, + Events: `{}`, + IsActive: false, + }) + hooks = append(hooks, &Webhook{ + OwnerID: 3, + RepoID: 3, + URL: "https://www.example.com/url3", + ContentType: ContentTypeJSON, + Events: `{"push_only":false,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":true}}`, + IsActive: true, + }) + hooks = append(hooks, &Webhook{ + OwnerID: 3, + RepoID: 3, + URL: "https://www.example.com/url3", + ContentType: ContentTypeJSON, + Events: `{}`, + }) + hooks = append(hooks, &Webhook{ + RepoID: 2, + URL: "https://www.example.com/url4", + ContentType: ContentTypeJSON, + Events: `{"push_only":true,"branch_filter":"{master,feature*}"}`, + IsActive: true, + }) + hooks = append(hooks, &Webhook{ + URL: "https://www.example.com/system", + ContentType: ContentTypeJSON, + Events: `{"push_only":true,"branch_filter":"{master,feature*}"}`, + IsSystemWebhook: true, + }) + hooks = append(hooks, &Webhook{ + URL: "https://www.example.com/default", + ContentType: ContentTypeJSON, + Events: `{"push_only":true,"branch_filter":"{master,feature*}"}`, + }) + ctx := context.Background() + if err := db.TruncateBeans(ctx, &Webhook{}); err != nil { + return err + } + if err := db.Insert(ctx, hooks); err != nil { + return err + } + + hook, _, _ := db.Get[Webhook](ctx, builder.Eq{"repo_id": 1, "is_active": true}) + var tasks []*HookTask + tasks = append(tasks, &HookTask{HookID: hook.ID, UUID: uuid.New().String()}) + tasks = append(tasks, &HookTask{HookID: hook.ID, UUID: uuid.New().String()}) + tasks = append(tasks, &HookTask{HookID: hook.ID, UUID: uuid.New().String()}) + if err := db.TruncateBeans(ctx, &HookTask{}); err != nil { + return err + } + return db.Insert(ctx, tasks) } -func TestIsValidHookContentType(t *testing.T) { +func TestWebHookContentType(t *testing.T) { + assert.Equal(t, "json", ContentTypeJSON.Name()) + assert.Equal(t, "form", ContentTypeForm.Name()) assert.True(t, IsValidHookContentType("json")) assert.True(t, IsValidHookContentType("form")) assert.False(t, IsValidHookContentType("invalid")) } func TestWebhook_History(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - webhook := unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 1}) - tasks, err := webhook.History(t.Context(), 0) - assert.NoError(t, err) - if assert.Len(t, tasks, 3) { - assert.Equal(t, int64(3), tasks[0].ID) - assert.Equal(t, int64(2), tasks[1].ID) - assert.Equal(t, int64(1), tasks[2].ID) - } + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{RepoID: 1, IsActive: true}) + tasks, err := hook.History(t.Context(), 0) + require.NoError(t, err) + require.Len(t, tasks, 3) - webhook = unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 2}) - tasks, err = webhook.History(t.Context(), 0) + hook = unittest.AssertExistsAndLoadBean(t, &Webhook{RepoID: 1, Events: "{}"}) + tasks, err = hook.History(t.Context(), 0) assert.NoError(t, err) assert.Empty(t, tasks) } func TestWebhook_UpdateEvent(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - webhook := unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 1}) + webhook := unittest.AssertExistsAndLoadBean(t, &Webhook{RepoID: 1, IsActive: true}) hookEvent := &webhook_module.HookEvent{ PushOnly: true, SendEverything: false, @@ -100,10 +166,10 @@ func TestCreateWebhook(t *testing.T) { } func TestGetWebhookByRepoID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - hook, err := GetWebhookByRepoID(t.Context(), 1, 1) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{RepoID: 1, IsActive: true}) + loaded, err := GetWebhookByRepoID(t.Context(), 1, hook.ID) assert.NoError(t, err) - assert.Equal(t, int64(1), hook.ID) + assert.Equal(t, hook.ID, loaded.ID) _, err = GetWebhookByRepoID(t.Context(), unittest.NonexistentID, unittest.NonexistentID) assert.Error(t, err) @@ -111,10 +177,10 @@ func TestGetWebhookByRepoID(t *testing.T) { } func TestGetWebhookByOwnerID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - hook, err := GetWebhookByOwnerID(t.Context(), 3, 3) - assert.NoError(t, err) - assert.Equal(t, int64(3), hook.ID) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{OwnerID: 3}) + loaded, err := GetWebhookByOwnerID(t.Context(), 3, hook.ID) + require.NoError(t, err) + require.Equal(t, hook.ID, loaded.ID) _, err = GetWebhookByOwnerID(t.Context(), unittest.NonexistentID, unittest.NonexistentID) assert.Error(t, err) @@ -122,48 +188,45 @@ func TestGetWebhookByOwnerID(t *testing.T) { } func TestGetActiveWebhooksByRepoID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{RepoID: 1, IsActive: true}) hooks, err := db.Find[Webhook](t.Context(), ListWebhookOptions{RepoID: 1, IsActive: optional.Some(true)}) - assert.NoError(t, err) - if assert.Len(t, hooks, 1) { - assert.Equal(t, int64(1), hooks[0].ID) - assert.True(t, hooks[0].IsActive) - } + require.NoError(t, err) + require.Len(t, hooks, 1) + assert.Equal(t, hook.ID, hooks[0].ID) + assert.True(t, hooks[0].IsActive) } func TestGetWebhooksByRepoID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) hooks, err := db.Find[Webhook](t.Context(), ListWebhookOptions{RepoID: 1}) - assert.NoError(t, err) - if assert.Len(t, hooks, 2) { - assert.Equal(t, int64(1), hooks[0].ID) - assert.Equal(t, int64(2), hooks[1].ID) - } + require.NoError(t, err) + require.Len(t, hooks, 2) + assert.Equal(t, int64(1), hooks[0].RepoID) + assert.True(t, hooks[0].IsActive) + assert.Equal(t, int64(1), hooks[1].RepoID) + assert.False(t, hooks[1].IsActive) } func TestGetActiveWebhooksByOwnerID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) hooks, err := db.Find[Webhook](t.Context(), ListWebhookOptions{OwnerID: 3, IsActive: optional.Some(true)}) - assert.NoError(t, err) - if assert.Len(t, hooks, 1) { - assert.Equal(t, int64(3), hooks[0].ID) - assert.True(t, hooks[0].IsActive) - } + require.NoError(t, err) + require.Len(t, hooks, 1) + assert.Equal(t, int64(3), hooks[0].OwnerID) + assert.True(t, hooks[0].IsActive) } func TestGetWebhooksByOwnerID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) hooks, err := db.Find[Webhook](t.Context(), ListWebhookOptions{OwnerID: 3}) - assert.NoError(t, err) - if assert.Len(t, hooks, 1) { - assert.Equal(t, int64(3), hooks[0].ID) - assert.True(t, hooks[0].IsActive) - } + require.NoError(t, err) + require.Len(t, hooks, 2) + assert.Equal(t, int64(3), hooks[0].OwnerID) + assert.True(t, hooks[0].IsActive) + assert.Equal(t, int64(3), hooks[1].OwnerID) + assert.False(t, hooks[1].IsActive) } func TestUpdateWebhook(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - hook := unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 2}) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{RepoID: 1, Events: `{}`}) + require.False(t, hook.IsActive) hook.IsActive = true hook.ContentType = ContentTypeForm unittest.AssertNotExistsBean(t, hook) @@ -172,48 +235,36 @@ func TestUpdateWebhook(t *testing.T) { } func TestDeleteWebhookByRepoID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 2, RepoID: 1}) - assert.NoError(t, DeleteWebhookByRepoID(t.Context(), 1, 2)) - unittest.AssertNotExistsBean(t, &Webhook{ID: 2, RepoID: 1}) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{RepoID: 1, Events: `{}`}) + assert.NoError(t, DeleteWebhookByRepoID(t.Context(), 1, hook.ID)) + unittest.AssertNotExistsBean(t, &Webhook{ID: hook.ID, RepoID: 1}) err := DeleteWebhookByRepoID(t.Context(), unittest.NonexistentID, unittest.NonexistentID) - assert.Error(t, err) assert.True(t, IsErrWebhookNotExist(err)) } func TestDeleteWebhookByOwnerID(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 3, OwnerID: 3}) - assert.NoError(t, DeleteWebhookByOwnerID(t.Context(), 3, 3)) - unittest.AssertNotExistsBean(t, &Webhook{ID: 3, OwnerID: 3}) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{OwnerID: 3, Events: `{}`}) + assert.NoError(t, DeleteWebhookByOwnerID(t.Context(), 3, hook.ID)) + unittest.AssertNotExistsBean(t, &Webhook{ID: hook.ID, OwnerID: 3}) err := DeleteWebhookByOwnerID(t.Context(), unittest.NonexistentID, unittest.NonexistentID) - assert.Error(t, err) assert.True(t, IsErrWebhookNotExist(err)) } func TestHookTasks(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - hookTasks, err := HookTasks(t.Context(), 1, 1) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{RepoID: 1, IsActive: true}) + hookTasks, err := HookTasks(t.Context(), hook.ID, 1) assert.NoError(t, err) - if assert.Len(t, hookTasks, 3) { - assert.Equal(t, int64(3), hookTasks[0].ID) - assert.Equal(t, int64(2), hookTasks[1].ID) - assert.Equal(t, int64(1), hookTasks[2].ID) - } - + assert.Len(t, hookTasks, 3) hookTasks, err = HookTasks(t.Context(), unittest.NonexistentID, 1) assert.NoError(t, err) assert.Empty(t, hookTasks) } func TestCreateHookTask(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - hookTask := &HookTask{ - HookID: 3, - PayloadVersion: 2, - } + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{OwnerID: 3, IsActive: true}) + hookTask := &HookTask{HookID: hook.ID, PayloadVersion: 2} unittest.AssertNotExistsBean(t, hookTask) _, err := CreateHookTask(t.Context(), hookTask) assert.NoError(t, err) @@ -221,20 +272,23 @@ func TestCreateHookTask(t *testing.T) { } func TestUpdateHookTask(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hook := unittest.AssertExistsAndLoadBean(t, &Webhook{OwnerID: 3, IsActive: true}) + hookTask := &HookTask{HookID: hook.ID, PayloadVersion: 2} + _, err := CreateHookTask(t.Context(), hookTask) + assert.NoError(t, err) - hook := unittest.AssertExistsAndLoadBean(t, &HookTask{ID: 1}) - hook.PayloadContent = "new payload content" - hook.IsDelivered = true - unittest.AssertNotExistsBean(t, hook) - assert.NoError(t, UpdateHookTask(t.Context(), hook)) - unittest.AssertExistsAndLoadBean(t, hook) + hookTask.PayloadContent = "new payload content" + hookTask.IsDelivered = true + unittest.AssertNotExistsBean(t, hookTask) + assert.NoError(t, UpdateHookTask(t.Context(), hookTask)) + unittest.AssertExistsAndLoadBean(t, hookTask) } func TestCleanupHookTaskTable_PerWebhook_DeletesDelivered(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hook := &Webhook{RepoID: 3, URL: "https://www.example.com/cleanup1", ContentType: ContentTypeJSON, Events: `{"push_only":true}`} + require.NoError(t, db.Insert(t.Context(), hook)) hookTask := &HookTask{ - HookID: 3, + HookID: hook.ID, IsDelivered: true, Delivered: timeutil.TimeStampNanoNow(), PayloadVersion: 2, @@ -249,9 +303,10 @@ func TestCleanupHookTaskTable_PerWebhook_DeletesDelivered(t *testing.T) { } func TestCleanupHookTaskTable_PerWebhook_LeavesUndelivered(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hook := &Webhook{RepoID: 3, URL: "https://www.example.com/cleanup2", ContentType: ContentTypeJSON, Events: `{"push_only":true}`} + require.NoError(t, db.Insert(t.Context(), hook)) hookTask := &HookTask{ - HookID: 4, + HookID: hook.ID, IsDelivered: false, PayloadVersion: 2, } @@ -265,9 +320,10 @@ func TestCleanupHookTaskTable_PerWebhook_LeavesUndelivered(t *testing.T) { } func TestCleanupHookTaskTable_PerWebhook_LeavesMostRecentTask(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hook := &Webhook{RepoID: 3, URL: "https://www.example.com/cleanup3", ContentType: ContentTypeJSON, Events: `{"push_only":true}`} + require.NoError(t, db.Insert(t.Context(), hook)) hookTask := &HookTask{ - HookID: 4, + HookID: hook.ID, IsDelivered: true, Delivered: timeutil.TimeStampNanoNow(), PayloadVersion: 2, @@ -282,9 +338,10 @@ func TestCleanupHookTaskTable_PerWebhook_LeavesMostRecentTask(t *testing.T) { } func TestCleanupHookTaskTable_OlderThan_DeletesDelivered(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hook := &Webhook{RepoID: 3, URL: "https://www.example.com/cleanup4", ContentType: ContentTypeJSON, Events: `{"push_only":true}`} + require.NoError(t, db.Insert(t.Context(), hook)) hookTask := &HookTask{ - HookID: 3, + HookID: hook.ID, IsDelivered: true, Delivered: timeutil.TimeStampNano(time.Now().AddDate(0, 0, -8).UnixNano()), PayloadVersion: 2, @@ -299,9 +356,10 @@ func TestCleanupHookTaskTable_OlderThan_DeletesDelivered(t *testing.T) { } func TestCleanupHookTaskTable_OlderThan_LeavesUndelivered(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hook := &Webhook{RepoID: 3, URL: "https://www.example.com/cleanup5", ContentType: ContentTypeJSON, Events: `{"push_only":true}`} + require.NoError(t, db.Insert(t.Context(), hook)) hookTask := &HookTask{ - HookID: 4, + HookID: hook.ID, IsDelivered: false, PayloadVersion: 2, } @@ -315,9 +373,10 @@ func TestCleanupHookTaskTable_OlderThan_LeavesUndelivered(t *testing.T) { } func TestCleanupHookTaskTable_OlderThan_LeavesTaskEarlierThanAgeToDelete(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) + hook := &Webhook{RepoID: 3, URL: "https://www.example.com/cleanup6", ContentType: ContentTypeJSON, Events: `{"push_only":true}`} + require.NoError(t, db.Insert(t.Context(), hook)) hookTask := &HookTask{ - HookID: 4, + HookID: hook.ID, IsDelivered: true, Delivered: timeutil.TimeStampNano(time.Now().AddDate(0, 0, -6).UnixNano()), PayloadVersion: 2, diff --git a/modules/actions/jobparser/jobparser.go b/modules/actions/jobparser/jobparser.go index 1d4c4c1756a..76f229a54b3 100644 --- a/modules/actions/jobparser/jobparser.go +++ b/modules/actions/jobparser/jobparser.go @@ -83,12 +83,6 @@ func Parse(content []byte, options ...ParseOption) ([]*SingleWorkflow, error) { return ret, nil } -func WithJobResults(results map[string]string) ParseOption { - return func(c *parseContext) { - c.jobResults = results - } -} - func WithGitContext(context *model.GithubContext) ParseOption { return func(c *parseContext) { c.gitContext = context diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index 4ac06def4d5..ba1aee7d72f 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -103,10 +103,20 @@ func GetEventsFromContent(content []byte) ([]*jobparser.Event, error) { if err != nil { return nil, err } + if err := ValidateWorkflowContent(content); err != nil { + return nil, err + } return events, nil } +// ValidateWorkflowContent catches structural errors (e.g. blank lines in run: | blocks) +// that model.ReadWorkflow alone does not detect. +func ValidateWorkflowContent(content []byte) error { + _, err := jobparser.Parse(content) + return err +} + func DetectWorkflows( gitRepo *git.Repository, commit *git.Commit, diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go index ea027366f7e..cda2de13e28 100644 --- a/modules/actions/workflows_test.go +++ b/modules/actions/workflows_test.go @@ -9,16 +9,26 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" webhook_module "code.gitea.io/gitea/modules/webhook" "github.com/stretchr/testify/assert" ) +func fullWorkflowContent(part string) []byte { + return []byte(` +name: test +` + part + ` +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo hello +`) +} + func TestIsWorkflow(t *testing.T) { - oldDirs := setting.Actions.WorkflowDirs - defer func() { - setting.Actions.WorkflowDirs = oldDirs - }() + defer test.MockVariableValue(&setting.Actions.WorkflowDirs)() tests := []struct { name string @@ -218,7 +228,7 @@ func TestDetectMatched(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - evts, err := GetEventsFromContent([]byte(tc.yamlOn)) + evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn)) assert.NoError(t, err) assert.Len(t, evts, 1) assert.Equal(t, tc.expected, detectMatched(nil, tc.commit, tc.triggedEvent, tc.payload, evts[0])) @@ -373,7 +383,7 @@ func TestMatchIssuesEvent(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - evts, err := GetEventsFromContent([]byte(tc.yamlOn)) + evts, err := GetEventsFromContent(fullWorkflowContent(tc.yamlOn)) assert.NoError(t, err) assert.Len(t, evts, 1) diff --git a/modules/avatar/avatar.go b/modules/avatar/avatar.go index 3b622b99af0..44c61e1ff67 100644 --- a/modules/avatar/avatar.go +++ b/modules/avatar/avatar.go @@ -11,15 +11,14 @@ import ( "image/color" "image/png" - _ "image/gif" // for processing gif images - _ "image/jpeg" // for processing jpeg images - "code.gitea.io/gitea/modules/avatar/identicon" "code.gitea.io/gitea/modules/setting" - "golang.org/x/image/draw" - _ "golang.org/x/image/webp" // for processing webp images + _ "image/gif" // for processing gif images + _ "image/jpeg" // for processing jpeg images + + "golang.org/x/image/draw" ) // DefaultAvatarSize is the target CSS pixel size for avatar generation. It is diff --git a/modules/dump/dumper.go b/modules/dump/dumper.go index 02829d6a1ed..2f160707041 100644 --- a/modules/dump/dumper.go +++ b/modules/dump/dumper.go @@ -4,6 +4,7 @@ package dump import ( + "archive/zip" "context" "errors" "fmt" @@ -85,7 +86,7 @@ func NewDumper(ctx context.Context, format string, output io.Writer) (*Dumper, e var comp archives.ArchiverAsync switch format { case "zip": - comp = archives.Zip{} + comp = archives.Zip{Compression: zip.Deflate} case "tar": comp = archives.Tar{} case "tar.sz": diff --git a/modules/eventsource/event.go b/modules/eventsource/event.go index ebcca509034..c72cc466a03 100644 --- a/modules/eventsource/event.go +++ b/modules/eventsource/event.go @@ -7,7 +7,6 @@ import ( "bytes" "fmt" "io" - "strings" "time" "code.gitea.io/gitea/modules/json" @@ -110,9 +109,3 @@ func (e *Event) WriteTo(w io.Writer) (int64, error) { return sum, err } - -func (e *Event) String() string { - buf := new(strings.Builder) - _, _ = e.WriteTo(buf) - return buf.String() -} diff --git a/modules/generate/generate.go b/modules/generate/generate.go index ac845044923..9baa057b17e 100644 --- a/modules/generate/generate.go +++ b/modules/generate/generate.go @@ -65,10 +65,5 @@ func NewJwtSecretWithBase64() ([]byte, string) { // NewSecretKey generate a new value intended to be used by SECRET_KEY. func NewSecretKey() (string, error) { - secretKey, err := util.CryptoRandomString(64) - if err != nil { - return "", err - } - - return secretKey, nil + return util.CryptoRandomString(64), nil } diff --git a/modules/git/catfile_batch_reader.go b/modules/git/catfile_batch_reader.go index 0c8fc740bee..4d77fb03c78 100644 --- a/modules/git/catfile_batch_reader.go +++ b/modules/git/catfile_batch_reader.go @@ -10,66 +10,49 @@ import ( "errors" "io" "math" + "slices" "strconv" "strings" "sync/atomic" - "time" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" ) -var catFileBatchDebugWaitClose atomic.Int64 - type catFileBatchCommunicator struct { - closeFunc func(err error) + closeFunc atomic.Pointer[func(err error)] reqWriter io.Writer respReader *bufio.Reader debugGitCmd *gitcmd.Command } -func (b *catFileBatchCommunicator) Close() { - if b.closeFunc != nil { - b.closeFunc(nil) - b.closeFunc = nil +func (b *catFileBatchCommunicator) Close(err ...error) { + if fn := b.closeFunc.Swap(nil); fn != nil { + (*fn)(util.OptionalArg(err)) } } -// newCatFileBatch opens git cat-file --batch in the provided repo and returns a stdin pipe, a stdout reader and cancel function -func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command) (ret *catFileBatchCommunicator) { +// newCatFileBatch opens git cat-file --batch/--batch-check/--batch-command command and prepares the stdin/stdout pipes for communication. +func newCatFileBatch(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command) *catFileBatchCommunicator { ctx, ctxCancel := context.WithCancelCause(ctx) - - // We often want to feed the commits in order into cat-file --batch, followed by their trees and subtrees as necessary. stdinWriter, stdoutReader, stdPipeClose := cmdCatFile.MakeStdinStdoutPipe() - pipeClose := func() { - if delay := catFileBatchDebugWaitClose.Load(); delay > 0 { - time.Sleep(time.Duration(delay)) // for testing purpose only - } - stdPipeClose() - } - closeFunc := func(err error) { - ctxCancel(err) - pipeClose() - } - return newCatFileBatchWithCloseFunc(ctx, repoPath, cmdCatFile, stdinWriter, stdoutReader, closeFunc) -} - -func newCatFileBatchWithCloseFunc(ctx context.Context, repoPath string, cmdCatFile *gitcmd.Command, - stdinWriter gitcmd.PipeWriter, stdoutReader gitcmd.PipeReader, closeFunc func(err error), -) *catFileBatchCommunicator { ret := &catFileBatchCommunicator{ debugGitCmd: cmdCatFile, - closeFunc: closeFunc, reqWriter: stdinWriter, respReader: bufio.NewReaderSize(stdoutReader, 32*1024), // use a buffered reader for rich operations } + ret.closeFunc.Store(new(func(err error) { + ctxCancel(err) + stdPipeClose() + })) err := cmdCatFile.WithDir(repoPath).StartWithStderr(ctx) if err != nil { log.Error("Unable to start git command %v: %v", cmdCatFile.LogString(), err) // ideally here it should return the error, but it would require refactoring all callers // so just return a dummy communicator that does nothing, almost the same behavior as before, not bad - closeFunc(err) + ret.Close(err) return ret } @@ -78,12 +61,33 @@ func newCatFileBatchWithCloseFunc(ctx context.Context, repoPath string, cmdCatFi if err != nil && !errors.Is(err, context.Canceled) { log.Error("cat-file --batch command failed in repo %s, error: %v", repoPath, err) } - closeFunc(err) + ret.Close(err) }() return ret } +func (b *catFileBatchCommunicator) debugKill() (ret struct { + beforeClose chan struct{} + blockClose chan struct{} + afterClose chan struct{} +}, +) { + ret.beforeClose = make(chan struct{}) + ret.blockClose = make(chan struct{}) + ret.afterClose = make(chan struct{}) + oldCloseFunc := b.closeFunc.Load() + b.closeFunc.Store(new(func(err error) { + b.closeFunc.Store(nil) + close(ret.beforeClose) + <-ret.blockClose + (*oldCloseFunc)(err) + close(ret.afterClose) + })) + b.debugGitCmd.DebugKill() + return ret +} + // catFileBatchParseInfoLine reads the header line from cat-file --batch // We expect: SP SP LF // then leaving the rest of the stream " LF" to be read @@ -169,77 +173,46 @@ headerLoop: return id, DiscardFull(rd, size-n+1) } -// git tree files are a list: -// SP NUL -// -// Unfortunately this 20-byte notation is somewhat in conflict to all other git tools -// Therefore we need some method to convert these binary hashes to hex hashes - // ParseCatFileTreeLine reads an entry from a tree in a cat-file --batch stream -// This carefully avoids allocations - except where fnameBuf is too small. -// It is recommended therefore to pass in an fnameBuf large enough to avoid almost all allocations -// -// Each line is composed of: -// SP NUL -// -// We don't attempt to convert the raw HASH to save a lot of time -func ParseCatFileTreeLine(objectFormat ObjectFormat, rd BufferedReader, modeBuf, fnameBuf, shaBuf []byte) (mode, fname, sha []byte, n int, err error) { - var readBytes []byte - - // Read the Mode & fname - readBytes, err = rd.ReadSlice('\x00') - if err != nil { - return mode, fname, sha, n, err - } - idx := bytes.IndexByte(readBytes, ' ') - if idx < 0 { - log.Debug("missing space in readBytes ParseCatFileTreeLine: %s", readBytes) - return mode, fname, sha, n, &ErrNotExist{} - } - - n += idx + 1 - copy(modeBuf, readBytes[:idx]) - if len(modeBuf) >= idx { - modeBuf = modeBuf[:idx] - } else { - modeBuf = append(modeBuf, readBytes[len(modeBuf):idx]...) - } - mode = modeBuf - - readBytes = readBytes[idx+1:] - - // Deal with the fname - copy(fnameBuf, readBytes) - if len(fnameBuf) > len(readBytes) { - fnameBuf = fnameBuf[:len(readBytes)] - } else { - fnameBuf = append(fnameBuf, readBytes[len(fnameBuf):]...) - } - for err == bufio.ErrBufferFull { - readBytes, err = rd.ReadSlice('\x00') - fnameBuf = append(fnameBuf, readBytes...) - } - n += len(fnameBuf) - if err != nil { - return mode, fname, sha, n, err - } - fnameBuf = fnameBuf[:len(fnameBuf)-1] - fname = fnameBuf - - // Deal with the binary hash - idx = 0 - length := objectFormat.FullLength() / 2 - for idx < length { - var read int - read, err = rd.Read(shaBuf[idx:length]) - n += read - if err != nil { - return mode, fname, sha, n, err +// Each entry is composed of: +// SP NUL +func ParseCatFileTreeLine(objectFormat ObjectFormat, rd BufferedReader) (mode EntryMode, name string, objID ObjectID, n int, err error) { + // use the in-buffer memory as much as possible to avoid extra allocations + bufBytes, err := rd.ReadSlice('\x00') + const maxEntryInfoBytes = 1024 * 1024 + if errors.Is(err, bufio.ErrBufferFull) { + bufBytes = slices.Clone(bufBytes) + for len(bufBytes) < maxEntryInfoBytes && errors.Is(err, bufio.ErrBufferFull) { + var tmp []byte + tmp, err = rd.ReadSlice('\x00') + bufBytes = append(bufBytes, tmp...) } - idx += read } - sha = shaBuf - return mode, fname, sha, n, err + if err != nil { + return mode, name, objID, len(bufBytes), err + } + + idx := bytes.IndexByte(bufBytes, ' ') + if idx < 0 { + return mode, name, objID, len(bufBytes), errors.New("invalid CatFileTreeLine output") + } + + mode = ParseEntryMode(util.UnsafeBytesToString(bufBytes[:idx])) + name = string(bufBytes[idx+1 : len(bufBytes)-1]) // trim the NUL terminator, it needs a copy because the bufBytes will be reused by the reader + if mode == EntryModeNoEntry { + return mode, name, objID, len(bufBytes), errors.New("invalid entry mode: " + string(bufBytes[:idx])) + } + + switch objectFormat { + case Sha1ObjectFormat: + objID = &Sha1Hash{} + case Sha256ObjectFormat: + objID = &Sha256Hash{} + default: + panic("unsupported object format: " + objectFormat.Name()) + } + readIDLen, err := io.ReadFull(rd, objID.RawValue()) + return mode, name, objID, len(bufBytes) + readIDLen, err } func DiscardFull(rd BufferedReader, discard int64) error { diff --git a/modules/git/catfile_batch_test.go b/modules/git/catfile_batch_test.go index 69662ffc1a7..782d34d2493 100644 --- a/modules/git/catfile_batch_test.go +++ b/modules/git/catfile_batch_test.go @@ -7,9 +7,7 @@ import ( "io" "os" "path/filepath" - "sync" "testing" - "time" "code.gitea.io/gitea/modules/test" @@ -39,13 +37,22 @@ func testCatFileBatch(t *testing.T) { require.Error(t, err) }) - simulateQueryTerminated := func(pipeCloseDelay, pipeReadDelay time.Duration) (errRead error) { - catFileBatchDebugWaitClose.Store(int64(pipeCloseDelay)) - defer catFileBatchDebugWaitClose.Store(0) + simulateQueryTerminated := func(t *testing.T, errBeforePipeClose, errAfterPipeClose error) { + readError := func(t *testing.T, r io.Reader, expectedErr error) { + if expectedErr == nil { + return // expectedErr == nil means this read should be skipped + } + n, err := r.Read(make([]byte, 100)) + assert.Zero(t, n) + assert.ErrorIs(t, err, expectedErr) + } + batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare")) require.NoError(t, err) defer batch.Close() - _, _ = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + _, err = batch.QueryInfo("e2129701f1a4d54dc44f03c93bca0a2aec7c5449") + require.NoError(t, err) + var c *catFileBatchCommunicator switch b := batch.(type) { case *catFileBatchLegacy: @@ -58,24 +65,18 @@ func testCatFileBatch(t *testing.T) { t.FailNow() } - wg := sync.WaitGroup{} - wg.Go(func() { - time.Sleep(pipeReadDelay) - var n int - n, errRead = c.respReader.Read(make([]byte, 100)) - assert.Zero(t, n) - }) - time.Sleep(10 * time.Millisecond) - c.debugGitCmd.DebugKill() - wg.Wait() - return errRead - } + require.NotEqual(t, errBeforePipeClose == nil, errAfterPipeClose == nil, "must set exactly one of the expected errors") + inceptor := c.debugKill() + <-inceptor.beforeClose // wait for the command's Close to be called, the pipe is not closed yet + readError(t, c.respReader, errBeforePipeClose) // then caller will read on an open pipe which will be closed soon + close(inceptor.blockClose) // continue to close the pipe + <-inceptor.afterClose // wait for the pipe to be closed + readError(t, c.respReader, errAfterPipeClose) // then caller will read on a closed pipe + } t.Run("QueryTerminated", func(t *testing.T) { - err := simulateQueryTerminated(0, 20*time.Millisecond) - assert.ErrorIs(t, err, os.ErrClosed) // pipes are closed faster - err = simulateQueryTerminated(40*time.Millisecond, 20*time.Millisecond) - assert.ErrorIs(t, err, io.EOF) // reader is faster + simulateQueryTerminated(t, io.EOF, nil) // reader is faster + simulateQueryTerminated(t, nil, os.ErrClosed) // pipes are closed faster }) batch, err := NewBatch(t.Context(), filepath.Join(testReposDir, "repo1_bare")) diff --git a/modules/git/git.go b/modules/git/git.go index 2df83f9843a..69eb07d1f0b 100644 --- a/modules/git/git.go +++ b/modules/git/git.go @@ -192,13 +192,13 @@ func RunGitTests(m interface{ Run() int }) { func runGitTests(m interface{ Run() int }) int { gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") if err != nil { - testlogger.Panicf("unable to create temp dir: %s", err.Error()) + return testlogger.MainErrorf("unable to create temp dir: %v", err) } defer cleanup() setting.Git.HomePath = gitHomePath if err = InitFull(); err != nil { - testlogger.Panicf("failed to call Init: %s", err.Error()) + return testlogger.MainErrorf("failed to call Init: %v", err) } return m.Run() } diff --git a/modules/git/gitcmd/command_test.go b/modules/git/gitcmd/command_test.go index 6e4214d9953..19ec02b8088 100644 --- a/modules/git/gitcmd/command_test.go +++ b/modules/git/gitcmd/command_test.go @@ -24,7 +24,7 @@ func testMain(m *testing.M) int { // "setting.Git.HomePath" is initialized in "git" package but really used in "gitcmd" package gitHomePath, cleanup, err := tempdir.OsTempDir("gitea-test").MkdirTempRandom("git-home") if err != nil { - testlogger.Panicf("failed to create temp dir: %v", err) + return testlogger.MainErrorf("failed to create temp dir: %v", err) } defer cleanup() diff --git a/modules/git/gitcmd/error.go b/modules/git/gitcmd/error.go index b674068c400..436f4e18ae4 100644 --- a/modules/git/gitcmd/error.go +++ b/modules/git/gitcmd/error.go @@ -56,6 +56,14 @@ func StderrHasPrefix(err error, prefix string) bool { return strings.HasPrefix(stderr, prefix) } +func StderrContains(err error, sub string) bool { + stderr, ok := ErrorAsStderr(err) + if !ok { + return false + } + return strings.Contains(stderr, sub) +} + func IsErrorExitCode(err error, code int) bool { var exitError *exec.ExitError if errors.As(err, &exitError) { diff --git a/modules/git/parse_treeentry.go b/modules/git/parse_treeentry.go index d46cd3344dc..23d59c19233 100644 --- a/modules/git/parse_treeentry.go +++ b/modules/git/parse_treeentry.go @@ -5,10 +5,7 @@ package git import ( "bytes" - "fmt" "io" - - "code.gitea.io/gitea/modules/log" ) // ParseTreeEntries parses the output of a `git ls-tree -l` command. @@ -47,14 +44,11 @@ func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { } func catBatchParseTreeEntries(objectFormat ObjectFormat, ptree *Tree, rd BufferedReader, sz int64) ([]*TreeEntry, error) { - fnameBuf := make([]byte, 4096) - modeBuf := make([]byte, 40) - shaBuf := make([]byte, objectFormat.FullLength()) entries := make([]*TreeEntry, 0, 10) loop: for sz > 0 { - mode, fname, sha, count, err := ParseCatFileTreeLine(objectFormat, rd, modeBuf, fnameBuf, shaBuf) + mode, fname, objID, count, err := ParseCatFileTreeLine(objectFormat, rd) if err != nil { if err == io.EOF { break loop @@ -64,25 +58,9 @@ loop: sz -= int64(count) entry := new(TreeEntry) entry.ptree = ptree - - switch string(mode) { - case "100644": - entry.entryMode = EntryModeBlob - case "100755": - entry.entryMode = EntryModeExec - case "120000": - entry.entryMode = EntryModeSymlink - case "160000": - entry.entryMode = EntryModeCommit - case "40000", "40755": // git uses 40000 for tree object, but some users may get 40755 for unknown reasons - entry.entryMode = EntryModeTree - default: - log.Debug("Unknown mode: %v", string(mode)) - return nil, fmt.Errorf("unknown mode: %v", string(mode)) - } - - entry.ID = objectFormat.MustID(sha) - entry.name = string(fname) + entry.entryMode = mode + entry.ID = objID + entry.name = fname entries = append(entries, entry) } if _, err := rd.Discard(1); err != nil { diff --git a/modules/git/parse_treeentry_test.go b/modules/git/parse_treeentry_test.go index 4223cbb3d76..5b81b49edda 100644 --- a/modules/git/parse_treeentry_test.go +++ b/modules/git/parse_treeentry_test.go @@ -4,6 +4,9 @@ package git import ( + "bufio" + "io" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -100,3 +103,31 @@ func TestParseTreeEntriesInvalid(t *testing.T) { assert.Error(t, err) assert.Empty(t, entries) } + +func TestParseCatFileTreeLine(t *testing.T) { + input := "100644 looooooooooooooooooooooooong-file-name.txt\x0012345678901234567890" + input += "40755 some-directory\x00abcdefg123abcdefg123" + + var readCount int + + buf := bufio.NewReaderSize(strings.NewReader(input), 20) // NewReaderSize has a limit: min buffer size = 16 + mode, name, objID, n, err := ParseCatFileTreeLine(Sha1ObjectFormat, buf) + readCount += n + assert.NoError(t, err) + assert.Equal(t, EntryModeBlob, mode) + assert.Equal(t, "looooooooooooooooooooooooong-file-name.txt", name) + assert.Equal(t, "12345678901234567890", string(objID.RawValue())) + + mode, name, objID, n, err = ParseCatFileTreeLine(Sha1ObjectFormat, buf) + readCount += n + assert.NoError(t, err) + assert.Equal(t, EntryModeTree, mode) + assert.Equal(t, "some-directory", name) + assert.Equal(t, "abcdefg123abcdefg123", string(objID.RawValue())) + + assert.Equal(t, len(input), readCount) + + _, _, _, n, err = ParseCatFileTreeLine(Sha1ObjectFormat, buf) + assert.ErrorIs(t, err, io.EOF) + assert.Zero(t, n) +} diff --git a/modules/git/pipeline/lfs_nogogit.go b/modules/git/pipeline/lfs_nogogit.go index 91bda0d0e5e..9a49dc81a28 100644 --- a/modules/git/pipeline/lfs_nogogit.go +++ b/modules/git/pipeline/lfs_nogogit.go @@ -8,7 +8,6 @@ package pipeline import ( "bufio" "bytes" - "encoding/hex" "io" "sort" "strings" @@ -46,10 +45,6 @@ func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader trees := []string{} paths := []string{} - fnameBuf := make([]byte, 4096) - modeBuf := make([]byte, 40) - workingShaBuf := make([]byte, objectID.Type().FullLength()/2) - for scan.Scan() { // Get the next commit ID commitID := scan.Text() @@ -93,23 +88,23 @@ func findLFSFileFunc(repo *git.Repository, objectID git.ObjectID, revListReader case "tree": var n int64 for n < info.Size { - mode, fname, binObjectID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader, modeBuf, fnameBuf, workingShaBuf) + mode, fname, shaID, count, err := git.ParseCatFileTreeLine(objectID.Type(), batchReader) if err != nil { return nil, err } n += int64(count) - if bytes.Equal(binObjectID, objectID.RawValue()) { + if bytes.Equal(shaID.RawValue(), objectID.RawValue()) { result := LFSResult{ - Name: curPath + string(fname), + Name: curPath + fname, SHA: curCommit.ID.String(), Summary: strings.Split(strings.TrimSpace(curCommit.CommitMessage), "\n")[0], When: curCommit.Author.When, ParentHashes: curCommit.Parents, } - resultsMap[curCommit.ID.String()+":"+curPath+string(fname)] = &result - } else if string(mode) == git.EntryModeTree.String() { - trees = append(trees, hex.EncodeToString(binObjectID)) - paths = append(paths, curPath+string(fname)+"/") + resultsMap[curCommit.ID.String()+":"+curPath+fname] = &result + } else if mode == git.EntryModeTree { + trees = append(trees, shaID.String()) + paths = append(paths, curPath+fname+"/") } } if _, err := batchReader.Discard(1); err != nil { diff --git a/modules/git/tree_entry_mode.go b/modules/git/tree_entry_mode.go index 2ceba113740..f80f6bdc750 100644 --- a/modules/git/tree_entry_mode.go +++ b/modules/git/tree_entry_mode.go @@ -66,9 +66,10 @@ func ParseEntryMode(mode string) EntryMode { return EntryModeSymlink case "160000": return EntryModeCommit - case "040000": + case "040000", "40000": // leading-zero is optional return EntryModeTree default: + // if the faster path didn't work, try parsing the mode as an integer and masking off the file type bits // git uses 040000 for tree object, but some users may get 040755 from non-standard git implementations m, _ := strconv.ParseInt(mode, 8, 32) modeInt := EntryMode(m) diff --git a/modules/git/tree_entry_test.go b/modules/git/tree_entry_test.go index 3df6eeab68d..bd6a5783b26 100644 --- a/modules/git/tree_entry_test.go +++ b/modules/git/tree_entry_test.go @@ -46,7 +46,9 @@ func TestParseEntryMode(t *testing.T) { {"160755", EntryModeCommit}, {"040000", EntryModeTree}, + {"40000", EntryModeTree}, {"040755", EntryModeTree}, + {"40755", EntryModeTree}, {"777777", EntryModeNoEntry}, // invalid mode } diff --git a/modules/gitrepo/commit.go b/modules/gitrepo/commit.go index 0ab17862fee..0a8cb0544cb 100644 --- a/modules/gitrepo/commit.go +++ b/modules/gitrepo/commit.go @@ -44,23 +44,6 @@ func CommitsCount(ctx context.Context, repo Repository, opts CommitsCountOptions return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) } -// CommitsCountBetween return numbers of commits between two commits -func CommitsCountBetween(ctx context.Context, repo Repository, start, end string) (int64, error) { - count, err := CommitsCount(ctx, repo, CommitsCountOptions{ - Revision: []string{start + ".." + end}, - }) - - if err != nil && strings.Contains(err.Error(), "no merge base") { - // future versions of git >= 2.28 are likely to return an error if before and last have become unrelated. - // previously it would return the results of git rev-list before last so let's try that... - return CommitsCount(ctx, repo, CommitsCountOptions{ - Revision: []string{start, end}, - }) - } - - return count, err -} - // FileCommitsCount return the number of files at a revision func FileCommitsCount(ctx context.Context, repo Repository, revision, file string) (int64, error) { return CommitsCount(ctx, repo, diff --git a/modules/gitrepo/config.go b/modules/gitrepo/config.go index 9be3ef94aeb..4940f1c0788 100644 --- a/modules/gitrepo/config.go +++ b/modules/gitrepo/config.go @@ -5,21 +5,11 @@ package gitrepo import ( "context" - "strings" "code.gitea.io/gitea/modules/git/gitcmd" "code.gitea.io/gitea/modules/globallock" ) -func GitConfigGet(ctx context.Context, repo Repository, key string) (string, error) { - result, _, err := RunCmdString(ctx, repo, gitcmd.NewCommand("config", "--get"). - AddDynamicArguments(key)) - if err != nil { - return "", err - } - return strings.TrimSpace(result), nil -} - func getRepoConfigLockKey(repoStoragePath string) string { return "repo-config:" + repoStoragePath } diff --git a/modules/graceful/manager_windows.go b/modules/graceful/manager_windows.go index 457768d6ca0..9592dd6b39c 100644 --- a/modules/graceful/manager_windows.go +++ b/modules/graceful/manager_windows.go @@ -1,5 +1,6 @@ // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT + // This code is heavily inspired by the archived gofacebook/gracenet/net.go handler //go:build windows diff --git a/modules/hostmatcher/hostmatcher.go b/modules/hostmatcher/hostmatcher.go index 15c63714222..044dba679a1 100644 --- a/modules/hostmatcher/hostmatcher.go +++ b/modules/hostmatcher/hostmatcher.go @@ -78,11 +78,6 @@ func (hl *HostMatchList) AppendBuiltin(builtin string) { hl.builtins = append(hl.builtins, builtin) } -// AppendPattern appends more pattern to match -func (hl *HostMatchList) AppendPattern(pattern string) { - hl.patterns = append(hl.patterns, pattern) -} - // IsEmpty checks if the checklist is empty func (hl *HostMatchList) IsEmpty() bool { return hl == nil || (len(hl.builtins) == 0 && len(hl.patterns) == 0 && len(hl.ipNets) == 0) diff --git a/modules/json/jsongoccy.go b/modules/json/jsongoccy.go deleted file mode 100644 index 77ea047fa71..00000000000 --- a/modules/json/jsongoccy.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2025 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package json - -import ( - "bytes" - "io" - - "github.com/goccy/go-json" -) - -var _ Interface = jsonGoccy{} - -type jsonGoccy struct{} - -func (jsonGoccy) Marshal(v any) ([]byte, error) { - return json.Marshal(v) -} - -func (jsonGoccy) Unmarshal(data []byte, v any) error { - return json.Unmarshal(data, v) -} - -func (jsonGoccy) NewEncoder(writer io.Writer) Encoder { - return json.NewEncoder(writer) -} - -func (jsonGoccy) NewDecoder(reader io.Reader) Decoder { - return json.NewDecoder(reader) -} - -func (jsonGoccy) Indent(dst *bytes.Buffer, src []byte, prefix, indent string) error { - return json.Indent(dst, src, prefix, indent) -} diff --git a/modules/json/jsonlegacy.go b/modules/json/jsonlegacy.go index 83eabad4526..81d644d4f4e 100644 --- a/modules/json/jsonlegacy.go +++ b/modules/json/jsonlegacy.go @@ -6,12 +6,12 @@ package json import ( - "encoding/json" + "encoding/json" //nolint:depguard // this package wraps it "io" ) func getDefaultJSONHandler() Interface { - return jsonGoccy{} + return jsonV1{} } func MarshalKeepOptionalEmpty(v any) ([]byte, error) { diff --git a/modules/markup/external/external.go b/modules/markup/external/external.go index 4d447e301ab..4b3c96fd33d 100644 --- a/modules/markup/external/external.go +++ b/modules/markup/external/external.go @@ -21,7 +21,33 @@ import ( // RegisterRenderers registers all supported third part renderers according settings func RegisterRenderers() { - markup.RegisterRenderer(&openAPIRenderer{}) + markup.RegisterRenderer(&frontendRenderer{ + name: "openapi-swagger", + patterns: []string{ + "openapi.yaml", + "openapi.yml", + "openapi.json", + "swagger.yaml", + "swagger.yml", + "swagger.json", + }, + }) + + markup.RegisterRenderer(&frontendRenderer{ + name: "viewer-3d", + patterns: []string{ + // It needs more logic to make it overall right (render a text 3D model automatically): + // we need to distinguish the ambiguous filename extensions. + // For example: "*.amf, *.obj, *.off, *.step" might be or not be a 3D model file. + // So when it is a text file, we can't assume that "we only render it by 3D plugin", + // otherwise the end users would be impossible to view its real content when the file is not a 3D model. + "*.3dm", "*.3ds", "*.3mf", "*.amf", "*.bim", "*.brep", + "*.dae", "*.fbx", "*.fcstd", "*.glb", "*.gltf", + "*.ifc", "*.igs", "*.iges", "*.stp", "*.step", + "*.stl", "*.obj", "*.off", "*.ply", "*.wrl", + }, + }) + for _, renderer := range setting.ExternalMarkupRenderers { markup.RegisterRenderer(&Renderer{renderer}) } diff --git a/modules/markup/external/frontend.go b/modules/markup/external/frontend.go new file mode 100644 index 00000000000..7327503d28a --- /dev/null +++ b/modules/markup/external/frontend.go @@ -0,0 +1,95 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package external + +import ( + "encoding/base64" + "io" + "unicode/utf8" + + "code.gitea.io/gitea/modules/htmlutil" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/public" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" +) + +type frontendRenderer struct { + name string + patterns []string +} + +var ( + _ markup.PostProcessRenderer = (*frontendRenderer)(nil) + _ 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: + // A. Make backend know everything (detect a file is a 3D model or not) + // B. Let frontend renders to try render one by one + // + // If there would be more frontend renders in the future, we need to implement the "frontend" approach: + // 1. Make backend or parent window collect the supported extensions of frontend renders (done: backend external render framework) + // 2. If the current file matches any extension, start the general iframe embedded render (done: this renderer) + // 3. The iframe window calls the frontend renders one by one (done: frontend external render) + // 4. Report the render result to parent by postMessage (TODO: when needed) + return p.patterns +} + +func (p *frontendRenderer) SanitizerRules() []setting.MarkupSanitizerRule { + return nil +} + +func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { + ret.SanitizerDisabled = true + ret.DisplayInIframe = true + ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" + return ret +} + +func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { + if ctx.RenderOptions.StandalonePageOptions == nil { + opts := p.GetExternalRendererOptions() + return markup.RenderIFrame(ctx, &opts, output) + } + + content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize)) + if err != nil { + return err + } + + contentEncoding, contentString := "text", util.UnsafeBytesToString(content) + if !utf8.Valid(content) { + contentEncoding = "base64" + contentString = base64.StdEncoding.EncodeToString(content) + } + + _, err = htmlutil.HTMLPrintf(output, + ` + + + + + + +
+ + + +`, + p.name, ctx.RenderOptions.RelativePath, + contentEncoding, contentString, + public.AssetURI("js/external-render-frontend.js")) + return err +} diff --git a/modules/markup/external/openapi.go b/modules/markup/external/openapi.go deleted file mode 100644 index 91230e54d02..00000000000 --- a/modules/markup/external/openapi.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package external - -import ( - "fmt" - "html" - "io" - - "code.gitea.io/gitea/modules/markup" - "code.gitea.io/gitea/modules/public" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" -) - -type openAPIRenderer struct{} - -var ( - _ markup.PostProcessRenderer = (*openAPIRenderer)(nil) - _ markup.ExternalRenderer = (*openAPIRenderer)(nil) -) - -func (p *openAPIRenderer) Name() string { - return "openapi" -} - -func (p *openAPIRenderer) NeedPostProcess() bool { - return false -} - -func (p *openAPIRenderer) FileNamePatterns() []string { - return []string{ - "openapi.yaml", - "openapi.yml", - "openapi.json", - "swagger.yaml", - "swagger.yml", - "swagger.json", - } -} - -func (p *openAPIRenderer) SanitizerRules() []setting.MarkupSanitizerRule { - return nil -} - -func (p *openAPIRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) { - ret.SanitizerDisabled = true - ret.DisplayInIframe = true - ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads" - return ret -} - -func (p *openAPIRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error { - if ctx.RenderOptions.StandalonePageOptions == nil { - opts := p.GetExternalRendererOptions() - return markup.RenderIFrame(ctx, &opts, output) - } - - content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize)) - if err != nil { - return err - } - - // HINT: SWAGGER-OPENAPI-VIEWER: another place "templates/swagger/openapi-viewer.tmpl" - _, err = io.WriteString(output, fmt.Sprintf( - ` - - - - - - -
- - -`, - public.AssetURI("css/swagger.css"), - html.EscapeString(ctx.RenderOptions.RelativePath), - html.EscapeString(util.UnsafeBytesToString(content)), - public.AssetURI("js/swagger.js"), - )) - return err -} diff --git a/modules/markup/html.go b/modules/markup/html.go index 1c2ae6918de..0fe37ae3052 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -6,6 +6,7 @@ package markup import ( "bytes" "fmt" + "html/template" "io" "regexp" "slices" @@ -149,9 +150,9 @@ func PostProcessDefault(ctx *RenderContext, input io.Reader, output io.Writer) e return postProcess(ctx, procs, input, output) } -// PostProcessCommitMessage will use the same logic as PostProcess, but will disable -// the shortLinkProcessor. -func PostProcessCommitMessage(ctx *RenderContext, content string) (string, error) { +// PostProcessCommitMessage will use the same logic as PostProcess, but will disable the shortLinkProcessor. +// FIXME: this function and its family have a very strange design: it takes HTML as input and output, processes the "escaped" content. +func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) (template.HTML, error) { procs := []processor{ fullIssuePatternProcessor, comparePatternProcessor, @@ -165,7 +166,8 @@ func PostProcessCommitMessage(ctx *RenderContext, content string) (string, error emojiProcessor, emojiShortCodeProcessor, } - return postProcessString(ctx, procs, content) + s, err := postProcessString(ctx, procs, string(content)) + return template.HTML(s), err } var emojiProcessors = []processor{ diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index e62747c7241..4fa9466d19a 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -317,7 +317,7 @@ func TestRender_email(t *testing.T) { func TestRender_emoji(t *testing.T) { setting.AppURL = markup.TestAppURL - setting.StaticURLPrefix = markup.TestAppURL + setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/") test := func(input, expected string) { expected = strings.ReplaceAll(expected, "&", "&") @@ -500,7 +500,7 @@ func Test_ParseClusterFuzz(t *testing.T) { } func TestPostProcess(t *testing.T) { - setting.StaticURLPrefix = markup.TestAppURL // can't run standalone + setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/") // can't run standalone defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)() test := func(input, expected string) { diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 555a171685b..4a560517f22 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -70,6 +70,8 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa } case *ast.CodeSpan: g.transformCodeSpan(ctx, v, reader) + case *ast.FencedCodeBlock: + g.transformFencedCodeblock(v, reader) case *ast.Blockquote: return g.transformBlockquote(v, reader) } diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index f6a6cb26c6d..1cc75d763d1 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -74,10 +74,6 @@ func (r *GlodmarkRender) Convert(source []byte, writer io.Writer, opts ...parser return r.goldmarkMarkdown.Convert(source, writer, opts...) } -func (r *GlodmarkRender) Renderer() renderer.Renderer { - return r.goldmarkMarkdown.Renderer() -} - func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) { if entering { languageBytes, _ := c.Language() diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index e231b037cc1..2f14a0fae98 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -600,3 +600,22 @@ func TestMarkdownUlDir(t *testing.T) { `, string(result)) } + +func TestMarkdownFencedCodeBlock(t *testing.T) { + testRender := func(input, expected string) { + buffer, err := markdown.RenderString(markup.NewTestRenderContext(), input) + assert.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) + } + const nl = "\n" + const prefix = `
`
+	const suffix = `
` + + testRender("```\ncode\n```", prefix+`code`+nl+``+suffix) + + const jsCommon = prefix + `code` + nl + `` + suffix + testRender("```js\ncode\n```", jsCommon) + testRender("```js:app.ts\ncode\n```", jsCommon) + testRender("```js,ignore\ncode\n```", jsCommon) + testRender("```js ignore\ncode\n```", jsCommon) +} diff --git a/modules/markup/markdown/transform_codeblock.go b/modules/markup/markdown/transform_codeblock.go new file mode 100644 index 00000000000..de9264c4c49 --- /dev/null +++ b/modules/markup/markdown/transform_codeblock.go @@ -0,0 +1,32 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func (g *ASTTransformer) transformFencedCodeblock(v *ast.FencedCodeBlock, reader text.Reader) { + // * Some engines support a meta syntax for appending the filename after the language, separated by a colon + // * https://www.glukhov.org/documentation-tools/markdown/markdown-codeblocks/ + // * Some engines support additional "options" after the language, separated by a space or comma: ```rust,ignore``` + // * https://docs.readme.com/rdmd/docs/code-blocks + // * https://next-book.vercel.app/reference/fencedcode + if v.Info == nil { + return + } + info := v.Info.Segment.Value(reader.Source()) + newEnd := -1 + for i, b := range info { + if b == ' ' || b == ',' || b == ':' { + newEnd = i + break + } + } + if newEnd != -1 { + start := v.Info.Segment.Start + v.Info = ast.NewTextSegment(text.NewSegment(start, start+newEnd)) + } +} diff --git a/modules/markup/render.go b/modules/markup/render.go index caed3428e0b..6e8838d49fe 100644 --- a/modules/markup/render.go +++ b/modules/markup/render.go @@ -6,6 +6,7 @@ package markup import ( "bytes" "context" + "errors" "fmt" "html/template" "io" @@ -43,7 +44,8 @@ type WebThemeInterface interface { } type StandalonePageOptions struct { - CurrentWebTheme WebThemeInterface + CurrentWebTheme WebThemeInterface + RenderQueryString string } type RenderOptions struct { @@ -206,17 +208,23 @@ func RenderString(ctx *RenderContext, content string) (string, error) { } func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error { + ownerName, repoName := ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"] + refSubURL := ctx.RenderOptions.Metas["RefTypeNameSubURL"] + if ownerName == "" || repoName == "" || refSubURL == "" { + setting.PanicInDevOrTesting("RenderIFrame requires user, repo and RefTypeNameSubURL metas") + return errors.New("RenderIFrame requires user, repo and RefTypeNameSubURL metas") + } src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL, - url.PathEscape(ctx.RenderOptions.Metas["user"]), - url.PathEscape(ctx.RenderOptions.Metas["repo"]), - util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]), + url.PathEscape(ownerName), + url.PathEscape(repoName), + ctx.RenderOptions.Metas["RefTypeNameSubURL"], util.PathEscapeSegments(ctx.RenderOptions.RelativePath), ) var extraAttrs template.HTML if opts.ContentSandbox != "" { extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox) } - _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs) + _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs) return err } @@ -228,7 +236,7 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) { } } -func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) { +func GetExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) { if externalRender, ok := renderer.(ExternalRenderer); ok { return externalRender.GetExternalRendererOptions(), true } @@ -237,7 +245,7 @@ func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { var extraHeadHTML template.HTML - if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe { + if extOpts, ok := GetExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe { if ctx.RenderOptions.StandalonePageOptions == nil { // for an external "DisplayInIFrame" render, it could only output its content in a standalone page // otherwise, a `, ret) + assert.Equal(t, ``, ret) ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"}) - assert.Equal(t, ``, ret) + assert.Equal(t, ``, ret) } diff --git a/modules/migration/schemas_bindata.go b/modules/migration/schemas_bindata.go index 2f0e2544081..d7c70815962 100644 --- a/modules/migration/schemas_bindata.go +++ b/modules/migration/schemas_bindata.go @@ -12,10 +12,10 @@ import ( "path" "sync" - _ "embed" - "code.gitea.io/gitea/modules/assetfs" + _ "embed" + "github.com/santhosh-tekuri/jsonschema/v6" ) diff --git a/modules/options/options_bindata.go b/modules/options/options_bindata.go index b2321d7eb50..f85f30065e2 100644 --- a/modules/options/options_bindata.go +++ b/modules/options/options_bindata.go @@ -10,9 +10,9 @@ package options import ( "sync" - _ "embed" - "code.gitea.io/gitea/modules/assetfs" + + _ "embed" ) //go:embed bindata.dat diff --git a/modules/packages/swift/metadata.go b/modules/packages/swift/metadata.go index 78925c6e6d9..d0137f8dfef 100644 --- a/modules/packages/swift/metadata.go +++ b/modules/packages/swift/metadata.go @@ -47,6 +47,7 @@ type Metadata struct { Keywords []string `json:"keywords,omitempty"` RepositoryURL string `json:"repository_url,omitempty"` License string `json:"license,omitempty"` + LicenseURL string `json:"license_url,omitempty"` Author Person `json:"author"` Manifests map[string]*Manifest `json:"manifests,omitempty"` } @@ -67,7 +68,8 @@ type SoftwareSourceCode struct { Keywords []string `json:"keywords,omitempty"` CodeRepository string `json:"codeRepository,omitempty"` License string `json:"license,omitempty"` - Author Person `json:"author"` + LicenseURL string `json:"licenseURL,omitempty"` + Author *Person `json:"author,omitempty"` ProgrammingLanguage ProgrammingLanguage `json:"programmingLanguage"` RepositoryURLs []string `json:"repositoryURLs,omitempty"` } @@ -181,26 +183,31 @@ func ParsePackage(sr io.ReaderAt, size int64, mr io.Reader) (*Package, error) { if err := json.NewDecoder(mr).Decode(&ssc); err != nil { return nil, err } - p.Metadata.Description = ssc.Description p.Metadata.Keywords = ssc.Keywords p.Metadata.License = ssc.License - author := Person{ - Name: ssc.Author.Name, - GivenName: ssc.Author.GivenName, - MiddleName: ssc.Author.MiddleName, - FamilyName: ssc.Author.FamilyName, + p.Metadata.LicenseURL = ssc.LicenseURL + if ssc.Author != nil { + author := Person{ + Name: ssc.Author.Name, + GivenName: ssc.Author.GivenName, + MiddleName: ssc.Author.MiddleName, + FamilyName: ssc.Author.FamilyName, + } + // If Name is not provided, generate it from individual name components + if author.Name == "" { + author.Name = author.String() + } + p.Metadata.Author = author } - // If Name is not provided, generate it from individual name components - if author.Name == "" { - author.Name = author.String() - } - p.Metadata.Author = author p.Metadata.RepositoryURL = ssc.CodeRepository if !validation.IsValidURL(p.Metadata.RepositoryURL) { p.Metadata.RepositoryURL = "" } + if !validation.IsValidURL(p.Metadata.LicenseURL) { + p.Metadata.LicenseURL = "" + } p.RepositoryURLs = ssc.RepositoryURLs } diff --git a/modules/packages/swift/metadata_test.go b/modules/packages/swift/metadata_test.go index 461773cbfce..440bcb9fac2 100644 --- a/modules/packages/swift/metadata_test.go +++ b/modules/packages/swift/metadata_test.go @@ -4,11 +4,12 @@ package swift import ( - "archive/zip" "bytes" "strings" "testing" + "code.gitea.io/gitea/modules/test" + "github.com/hashicorp/go-version" "github.com/stretchr/testify/assert" ) @@ -18,36 +19,24 @@ const ( packageVersion = "1.0.1" packageDescription = "Package Description" packageRepositoryURL = "https://gitea.io/gitea/gitea" + packageLicenseURL = "https://opensource.org/license/mit" packageAuthor = "KN4CK3R" packageLicense = "MIT" ) func TestParsePackage(t *testing.T) { - createArchive := func(files map[string][]byte) *bytes.Reader { - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - for filename, content := range files { - w, _ := zw.Create(filename) - w.Write(content) - } - zw.Close() - return bytes.NewReader(buf.Bytes()) - } - t.Run("MissingManifestFile", func(t *testing.T) { - data := createArchive(map[string][]byte{"dummy.txt": {}}) - - p, err := ParsePackage(data, data.Size(), nil) + data := test.WriteZipArchive(map[string]string{"dummy.txt": ""}) + p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil) assert.Nil(t, p) assert.ErrorIs(t, err, ErrMissingManifestFile) }) t.Run("ManifestFileTooLarge", func(t *testing.T) { - data := createArchive(map[string][]byte{ - "Package.swift": make([]byte, maxManifestFileSize+1), + data := test.WriteZipArchive(map[string]string{ + "Package.swift": strings.Repeat("a", maxManifestFileSize+1), }) - - p, err := ParsePackage(data, data.Size(), nil) + p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil) assert.Nil(t, p) assert.ErrorIs(t, err, ErrManifestFileTooLarge) }) @@ -56,12 +45,12 @@ func TestParsePackage(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" - data := createArchive(map[string][]byte{ - "Package.swift": []byte(content1), - "Package@swift-5.5.swift": []byte(content2), + data := test.WriteZipArchive(map[string]string{ + "Package.swift": content1, + "Package@swift-5.5.swift": content2, }) - p, err := ParsePackage(data, data.Size(), nil) + p, err := ParsePackage(bytes.NewReader(data.Bytes()), int64(data.Len()), nil) assert.NotNil(t, p) assert.NoError(t, err) @@ -77,14 +66,13 @@ func TestParsePackage(t *testing.T) { }) t.Run("WithMetadata", func(t *testing.T) { - data := createArchive(map[string][]byte{ - "Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"), + data := test.WriteZipArchive(map[string]string{ + "Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift", }) p, err := ParsePackage( - data, - data.Size(), - strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","keywords":["swift","package"],"license":"`+packageLicense+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`), + bytes.NewReader(data.Bytes()), int64(data.Len()), + strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","keywords":["swift","package"],"license":"`+packageLicense+`","licenseURL":"`+packageLicenseURL+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`), ) assert.NotNil(t, p) assert.NoError(t, err) @@ -97,6 +85,7 @@ func TestParsePackage(t *testing.T) { assert.Equal(t, packageDescription, p.Metadata.Description) assert.ElementsMatch(t, []string{"swift", "package"}, p.Metadata.Keywords) assert.Equal(t, packageLicense, p.Metadata.License) + assert.Equal(t, packageLicenseURL, p.Metadata.LicenseURL) assert.Equal(t, packageAuthor, p.Metadata.Author.Name) assert.Equal(t, packageAuthor, p.Metadata.Author.GivenName) assert.Equal(t, packageRepositoryURL, p.Metadata.RepositoryURL) @@ -104,14 +93,13 @@ func TestParsePackage(t *testing.T) { }) t.Run("WithExplicitNameField", func(t *testing.T) { - data := createArchive(map[string][]byte{ - "Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"), + data := test.WriteZipArchive(map[string]string{ + "Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift", }) authorName := "John Doe" p, err := ParsePackage( - data, - data.Size(), + bytes.NewReader(data.Bytes()), int64(data.Len()), strings.NewReader(`{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","author":{"name":"`+authorName+`","givenName":"John","familyName":"Doe"}}`), ) assert.NotNil(t, p) @@ -122,15 +110,30 @@ func TestParsePackage(t *testing.T) { assert.Equal(t, "Doe", p.Metadata.Author.FamilyName) }) + t.Run("WithEmptyJSONMetadata", func(t *testing.T) { + data := test.WriteZipArchive(map[string]string{ + "Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift", + }) + + p, err := ParsePackage( + bytes.NewReader(data.Bytes()), int64(data.Len()), + strings.NewReader(`{}`), + ) + assert.NotNil(t, p) + assert.NoError(t, err) + assert.NotNil(t, p.Metadata) + assert.Empty(t, p.Metadata.Author.Name) + assert.Empty(t, p.RepositoryURLs) + }) + t.Run("NameFieldGeneration", func(t *testing.T) { - data := createArchive(map[string][]byte{ - "Package.swift": []byte("// swift-tools-version:5.7\n//\n// Package.swift"), + data := test.WriteZipArchive(map[string]string{ + "Package.swift": "// swift-tools-version:5.7\n//\n// Package.swift", }) // Test with only individual name components - Name should be auto-generated p, err := ParsePackage( - data, - data.Size(), + bytes.NewReader(data.Bytes()), int64(data.Len()), strings.NewReader(`{"author":{"givenName":"John","middleName":"Q","familyName":"Doe"}}`), ) assert.NotNil(t, p) diff --git a/modules/public/manifest.go b/modules/public/manifest.go index a07cabd6cf1..f807244c893 100644 --- a/modules/public/manifest.go +++ b/modules/public/manifest.go @@ -56,6 +56,8 @@ func parseManifest(data []byte) (map[string]string, map[string]string) { paths[key] = entry.File names[entry.File] = entry.Name // Map associated CSS files, e.g. "css/index.css" -> "css/index.B3zrQPqD.css" + // FIXME: INCORRECT-VITE-MANIFEST-PARSER: the logic is wrong, Vite manifest doesn't work this way + // It just happens to be correct for the current modules dependencies for _, css := range entry.CSS { cssKey := path.Dir(css) + "/" + entry.Name + path.Ext(css) paths[cssKey] = css diff --git a/modules/public/vitedev.go b/modules/public/vitedev.go index 7cfe692390b..e6be460599a 100644 --- a/modules/public/vitedev.go +++ b/modules/public/vitedev.go @@ -87,6 +87,7 @@ func getViteDevProxy() *httputil.ReverseProxy { // the Vite dev server port from the port file written by the viteDevServerPortPlugin. // It is needed because there are container-based development, only Gitea web server's port is exposed. func ViteDevMiddleware(next http.Handler) http.Handler { + markLongPolling := routing.MarkLongPolling() return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { if !isViteDevRequest(req) { next.ServeHTTP(resp, req) @@ -97,8 +98,7 @@ func ViteDevMiddleware(next http.Handler) http.Handler { next.ServeHTTP(resp, req) return } - routing.MarkLongPolling(resp, req) - proxy.ServeHTTP(resp, req) + markLongPolling(proxy).ServeHTTP(resp, req) }) } diff --git a/modules/queue/manager_test.go b/modules/queue/manager_test.go index fda498cc841..f9f9b7310be 100644 --- a/modules/queue/manager_test.go +++ b/modules/queue/manager_test.go @@ -13,11 +13,7 @@ import ( ) func TestManager(t *testing.T) { - oldAppDataPath := setting.AppDataPath setting.AppDataPath = t.TempDir() - defer func() { - setting.AppDataPath = oldAppDataPath - }() newQueueFromConfig := func(name, cfg string) (*WorkerPoolQueue[int], error) { cfgProvider, err := setting.NewConfigProviderFromData(cfg) diff --git a/modules/session/redis.go b/modules/session/redis.go index 083869f4e1e..f5cac8e636c 100644 --- a/modules/session/redis.go +++ b/modules/session/redis.go @@ -1,18 +1,6 @@ // Copyright 2013 Beego Authors // Copyright 2014 The Macaron Authors // Copyright 2020 The Gitea Authors. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"): you may -// not use this file except in compliance with the License. You may obtain -// a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -// License for the specific language governing permissions and limitations -// under the License. // SPDX-License-Identifier: Apache-2.0 package session diff --git a/modules/setting/actions.go b/modules/setting/actions.go index 7a91ecb5930..0d1bdadc8ec 100644 --- a/modules/setting/actions.go +++ b/modules/setting/actions.go @@ -12,6 +12,8 @@ import ( "code.gitea.io/gitea/modules/log" ) +const defaultMaxRerunAttempts = 50 + // Actions settings var ( Actions = struct { @@ -27,11 +29,13 @@ var ( AbandonedJobTimeout time.Duration `ini:"ABANDONED_JOB_TIMEOUT"` SkipWorkflowStrings []string `ini:"SKIP_WORKFLOW_STRINGS"` WorkflowDirs []string `ini:"WORKFLOW_DIRS"` + MaxRerunAttempts int64 `ini:"MAX_RERUN_ATTEMPTS"` }{ Enabled: true, DefaultActionsURL: defaultActionsURLGitHub, SkipWorkflowStrings: []string{"[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"}, WorkflowDirs: []string{".gitea/workflows", ".github/workflows"}, + MaxRerunAttempts: defaultMaxRerunAttempts, } ) @@ -118,6 +122,10 @@ func loadActionsFrom(rootCfg ConfigProvider) error { Actions.EndlessTaskTimeout = sec.Key("ENDLESS_TASK_TIMEOUT").MustDuration(3 * time.Hour) Actions.AbandonedJobTimeout = sec.Key("ABANDONED_JOB_TIMEOUT").MustDuration(24 * time.Hour) + if Actions.MaxRerunAttempts <= 0 { + Actions.MaxRerunAttempts = defaultMaxRerunAttempts + } + if !Actions.LogCompression.IsValid() { return fmt.Errorf("invalid [actions] LOG_COMPRESSION: %q", Actions.LogCompression) } diff --git a/modules/setting/oauth2.go b/modules/setting/oauth2.go index 8e0210aa518..83891387a77 100644 --- a/modules/setting/oauth2.go +++ b/modules/setting/oauth2.go @@ -99,6 +99,7 @@ var OAuth2 = struct { JWTClaimIssuer string `ini:"JWT_CLAIM_ISSUER"` MaxTokenLength int DefaultApplications []string + CustomSchemes []string }{ Enabled: true, AccessTokenExpirationTime: 3600, diff --git a/modules/setting/path.go b/modules/setting/path.go index f51457a620e..45c6759a73e 100644 --- a/modules/setting/path.go +++ b/modules/setting/path.go @@ -198,6 +198,12 @@ func InitWorkPathAndCfgProvider(getEnvFn func(name string) string, args ArgWorkP CustomConf = tmpCustomConf.Value } +func MockBuiltinPaths(workPath, customPath, customConf string) func() { + oldApp, oldCustom, oldConf := appWorkPathBuiltin, customPathBuiltin, customConfBuiltin + appWorkPathBuiltin, customPathBuiltin, customConfBuiltin = workPath, customPath, customConf + return func() { appWorkPathBuiltin, customPathBuiltin, customConfBuiltin = oldApp, oldCustom, oldConf } +} + // AppDataTempDir returns a managed temporary directory for the application data. // Using empty sub will get the managed base temp directory, and it's safe to delete it. // Gitea only creates subdirectories under it, but not the APP_TEMP_PATH directory itself. diff --git a/modules/setting/security.go b/modules/setting/security.go index a1fd0bce2e5..8b7664baba3 100644 --- a/modules/setting/security.go +++ b/modules/setting/security.go @@ -16,9 +16,11 @@ import ( // Security settings var Security = struct { // TODO: move more settings to this struct in future - XFrameOptions string + XFrameOptions string + XContentTypeOptions string }{ - XFrameOptions: "SAMEORIGIN", + XFrameOptions: "SAMEORIGIN", + XContentTypeOptions: "nosniff", } var ( @@ -31,6 +33,7 @@ var ( ReverseProxyAuthEmail string ReverseProxyAuthFullName string ReverseProxyLimit int + ReverseProxyLogoutRedirect string ReverseProxyTrustedProxies []string MinPasswordLength int ImportLocalPaths bool @@ -124,6 +127,7 @@ func loadSecurityFrom(rootCfg ConfigProvider) { ReverseProxyAuthFullName = sec.Key("REVERSE_PROXY_AUTHENTICATION_FULL_NAME").MustString("X-WEBAUTH-FULLNAME") ReverseProxyLimit = sec.Key("REVERSE_PROXY_LIMIT").MustInt(1) + ReverseProxyLogoutRedirect = sec.Key("REVERSE_PROXY_LOGOUT_REDIRECT").String() ReverseProxyTrustedProxies = sec.Key("REVERSE_PROXY_TRUSTED_PROXIES").Strings(",") if len(ReverseProxyTrustedProxies) == 0 { ReverseProxyTrustedProxies = []string{"127.0.0.0/8", "::1/128"} @@ -152,6 +156,8 @@ func loadSecurityFrom(rootCfg ConfigProvider) { Security.XFrameOptions = rootCfg.Section("cors").Key("X_FRAME_OPTIONS").MustString(Security.XFrameOptions) } + Security.XContentTypeOptions = sec.Key("X_CONTENT_TYPE_OPTIONS").MustString(Security.XContentTypeOptions) + twoFactorAuth := sec.Key("TWO_FACTOR_AUTH").String() switch twoFactorAuth { case "": diff --git a/modules/setting/server.go b/modules/setting/server.go index 1085e052a3e..dc58e43c435 100644 --- a/modules/setting/server.go +++ b/modules/setting/server.go @@ -4,7 +4,6 @@ package setting import ( - "encoding/base64" "net" "net/url" "os" @@ -13,7 +12,6 @@ import ( "strings" "time" - "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" ) @@ -112,72 +110,9 @@ var ( StartupTimeout time.Duration PerWriteTimeout = 30 * time.Second PerWritePerKbTimeout = 10 * time.Second - StaticURLPrefix string - AbsoluteAssetURL string - - ManifestData string + StaticURLPrefix string // no trailing slash, defaults to AppSubURL, the URL can be relative or absolute ) -// MakeManifestData generates web app manifest JSON -func MakeManifestData(appName, appURL, absoluteAssetURL string) []byte { - type manifestIcon struct { - Src string `json:"src"` - Type string `json:"type"` - Sizes string `json:"sizes"` - } - - type manifestJSON struct { - Name string `json:"name"` - ShortName string `json:"short_name"` - StartURL string `json:"start_url"` - Icons []manifestIcon `json:"icons"` - } - - bytes, err := json.Marshal(&manifestJSON{ - Name: appName, - ShortName: appName, - StartURL: appURL, - Icons: []manifestIcon{ - { - Src: absoluteAssetURL + "/assets/img/logo.png", - Type: "image/png", - Sizes: "512x512", - }, - { - Src: absoluteAssetURL + "/assets/img/logo.svg", - Type: "image/svg+xml", - Sizes: "512x512", - }, - }, - }) - if err != nil { - log.Error("unable to marshal manifest JSON. Error: %v", err) - return make([]byte, 0) - } - - return bytes -} - -// MakeAbsoluteAssetURL returns the absolute asset url prefix without a trailing slash -func MakeAbsoluteAssetURL(appURL *url.URL, staticURLPrefix string) string { - parsedPrefix, err := url.Parse(strings.TrimSuffix(staticURLPrefix, "/")) - if err != nil { - log.Fatal("Unable to parse STATIC_URL_PREFIX: %v", err) - } - - if err == nil && parsedPrefix.Hostname() == "" { - if staticURLPrefix == "" { - return strings.TrimSuffix(appURL.String(), "/") - } - - // StaticURLPrefix is just a path - appHostURL := &url.URL{Scheme: appURL.Scheme, Host: appURL.Host} - return appHostURL.String() + "/" + strings.Trim(staticURLPrefix, "/") - } - - return strings.TrimSuffix(staticURLPrefix, "/") -} - func loadServerFrom(rootCfg ConfigProvider) { sec := rootCfg.Section("server") AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea") @@ -313,10 +248,6 @@ func loadServerFrom(rootCfg ConfigProvider) { Domain = urlHostname } - AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix) - manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL) - ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes) - var defaultLocalURL string switch Protocol { case HTTPUnix: diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 2009be0bbd3..3c1ad144282 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -201,7 +201,7 @@ func mustCurrentRunUserMatch(rootCfg ConfigProvider) { if HasInstallLock(rootCfg) { currentUser, match := IsRunUserMatchCurrentUser(RunUser) if !match { - log.Fatal("Expect user '%s' but current user is: %s", RunUser, currentUser) + log.Fatal("Expect user '%s' (RUN_USER in app.ini) but current user is: %s", RunUser, currentUser) } } } diff --git a/modules/setting/setting_test.go b/modules/setting/setting_test.go deleted file mode 100644 index 13575f52a6e..00000000000 --- a/modules/setting/setting_test.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2020 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package setting - -import ( - "net/url" - "testing" - - "code.gitea.io/gitea/modules/json" - - "github.com/stretchr/testify/assert" -) - -func TestMakeAbsoluteAssetURL(t *testing.T) { - appURL1, _ := url.Parse("https://localhost:1234") - appURL2, _ := url.Parse("https://localhost:1234/") - appURLSub1, _ := url.Parse("https://localhost:1234/foo") - appURLSub2, _ := url.Parse("https://localhost:1234/foo/") - - // static URL is an absolute URL, so should be used - assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL(appURL1, "https://localhost:2345")) - assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL(appURL1, "https://localhost:2345/")) - - assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL1, "/foo")) - assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL2, "/foo")) - assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL1, "/foo/")) - - assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub1, "/foo")) - assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub2, "/foo")) - assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub1, "/foo/")) - - assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub1, "/bar")) - assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub2, "/bar")) - assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub1, "/bar/")) -} - -func TestMakeManifestData(t *testing.T) { - jsonBytes := MakeManifestData(`Example App '\"`, "https://example.com", "https://example.com/foo/bar") - assert.True(t, json.Valid(jsonBytes)) -} diff --git a/modules/setting/testenv.go b/modules/setting/testenv.go index 853521c328a..d8663d07e24 100644 --- a/modules/setting/testenv.go +++ b/modules/setting/testenv.go @@ -10,10 +10,12 @@ import ( "runtime" "strings" + "code.gitea.io/gitea/modules/auth/password/hash" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/util" ) -var giteaTestSourceRoot *string +var giteaTestSourceRoot *string // intentionally use a pointer to make sure the uninitialized access panics func GetGiteaTestSourceRoot() string { return *giteaTestSourceRoot @@ -25,48 +27,93 @@ func SetupGiteaTestEnv() { } IsInTesting = true - giteaRoot := os.Getenv("GITEA_TEST_ROOT") - if giteaRoot == "" { - _, filename, _, _ := runtime.Caller(0) - giteaRoot = filepath.Dir(filepath.Dir(filepath.Dir(filename))) - fixturesDir := filepath.Join(giteaRoot, "models", "fixtures") - if _, err := os.Stat(fixturesDir); err != nil { - panic("in gitea source code directory, fixtures directory not found: " + fixturesDir) + + log.OsExiter = func(code int) { + if code != 0 { + // Non-zero exit code (log.Fatal) shouldn't occur during testing, if it happens: + // * Show a full stacktrace for more details. + // * If the "log.Fatal" is abused in tests, should fix. + panic(fmt.Errorf("non-zero exit code during testing: %d", code)) + } + os.Exit(0) + } + + initGiteaRoot := func() string { + giteaRoot := os.Getenv("GITEA_TEST_ROOT") + if giteaRoot == "" { + _, filename, _, _ := runtime.Caller(0) + giteaRoot = filepath.Dir(filepath.Dir(filepath.Dir(filename))) + fixturesDir := filepath.Join(giteaRoot, "models", "fixtures") + if _, err := os.Stat(fixturesDir); err != nil { + panic("in gitea source code directory, fixtures directory not found: " + fixturesDir) + } + } + giteaTestSourceRoot = &giteaRoot + return giteaRoot + } + giteaRoot := initGiteaRoot() + + initGiteaPaths := func() { + // need to load assets (options, public) from the source code directory for testing + StaticRootPath = giteaRoot + // during testing, the AppPath must point to the pre-built Gitea binary in the source root + // it needs to be called by git hooks + AppPath = filepath.Join(giteaRoot, "gitea") + util.Iif(IsWindows, ".exe", "") + } + + initGiteaConf := func() string { + // giteaConf (GITEA_CONF) must be relative because it is used in the git hooks as "$GITEA_ROOT/$GITEA_CONF" + giteaConf := os.Getenv("GITEA_TEST_CONF") + if giteaConf == "" { + // if no GITEA_TEST_CONF, then it is in unit test, use a temp (non-existing / empty) config file + // do not really use such config file, the test can run concurrently, using the same config file will cause data-race between tests + giteaConf = "custom/conf/app-test-tmp.ini" + customConfBuiltin = filepath.Join(AppWorkPath, giteaConf) + CustomConf = customConfBuiltin + _ = os.Remove(CustomConf) + } else { + // CustomConf must be absolute path to make tests pass. + // At the moment, GITEA_TEST_CONF is always in Gitea's source root + CustomConf = filepath.Join(giteaRoot, giteaConf) + } + return giteaConf + } + + cleanUpEnv := func() { + // also unset unnecessary env vars for testing (only keep "GITEA_TEST_*" ones) + UnsetUnnecessaryEnvVars() + for _, env := range os.Environ() { + if strings.HasPrefix(env, "GIT_") || (strings.HasPrefix(env, "GITEA_") && !strings.HasPrefix(env, "GITEA_TEST_")) { + k, _, _ := strings.Cut(env, "=") + _ = os.Unsetenv(k) + } } } - appWorkPathBuiltin = giteaRoot - AppWorkPath = giteaRoot - AppPath = filepath.Join(giteaRoot, "gitea") + util.Iif(IsWindows, ".exe", "") - StaticRootPath = giteaRoot // need to load assets (options, public) from the source code directory for testing + initWorkPathAndConfig := func() { + // init paths and config system for testing + getTestEnv := func(key string) string { return "" } + InitWorkPathAndCommonConfig(getTestEnv, ArgWorkPathAndCustomConf{CustomConf: CustomConf}) - // giteaConf (GITEA_CONF) must be relative because it is used in the git hooks as "$GITEA_ROOT/$GITEA_CONF" - giteaConf := os.Getenv("GITEA_TEST_CONF") - if giteaConf == "" { - // By default, use sqlite.ini for testing, then IDE like GoLand can start the test process with debugger. - // It's easier for developers to debug bugs step by step with a debugger. - // Notice: when doing "ssh push", Gitea executes sub processes, debugger won't work for the sub processes. - giteaConf = "tests/sqlite.ini" - _, _ = fmt.Fprintf(os.Stderr, "Environment variable GITEA_TEST_CONF not set - defaulting to %s\n", giteaConf) - if !EnableSQLite3 { - _, _ = fmt.Fprintf(os.Stderr, "sqlite3 requires: -tags sqlite,sqlite_unlock_notify\n") - os.Exit(1) + if err := PrepareAppDataPath(); err != nil { + log.Fatal("Can not prepare APP_DATA_PATH: %v", err) } + + // register the dummy hash algorithm function used in the test fixtures + _ = hash.Register("dummy", hash.NewDummyHasher) + PasswordHashAlgo, _ = hash.SetDefaultPasswordHashAlgorithm("dummy") } - // CustomConf must be absolute path to make tests pass, - CustomConf = filepath.Join(AppWorkPath, giteaConf) - // also unset unnecessary env vars for testing (only keep "GITEA_TEST_*" ones) - UnsetUnnecessaryEnvVars() - for _, env := range os.Environ() { - if strings.HasPrefix(env, "GIT_") || (strings.HasPrefix(env, "GITEA_") && !strings.HasPrefix(env, "GITEA_TEST_")) { - k, _, _ := strings.Cut(env, "=") - _ = os.Unsetenv(k) - } + initGiteaPaths() + giteaConf := initGiteaConf() + cleanUpEnv() + initWorkPathAndConfig() + + if RepoRootPath == "" || AppDataPath == "" { + panic("SetupGiteaTestEnv failed, paths are not initialized") } // TODO: some git repo hooks (test fixtures) still use these env variables, need to be refactored in the future _ = os.Setenv("GITEA_ROOT", giteaRoot) _ = os.Setenv("GITEA_CONF", giteaConf) // test fixture git hooks use "$GITEA_ROOT/$GITEA_CONF" in their scripts - giteaTestSourceRoot = &giteaRoot } diff --git a/modules/storage/storage.go b/modules/storage/storage.go index e19c421ba82..1271440e5a8 100644 --- a/modules/storage/storage.go +++ b/modules/storage/storage.go @@ -21,25 +21,6 @@ import ( // ErrURLNotSupported represents url is not supported var ErrURLNotSupported = errors.New("url method not supported") -// ErrInvalidConfiguration is called when there is invalid configuration for a storage -type ErrInvalidConfiguration struct { - cfg any - err error -} - -func (err ErrInvalidConfiguration) Error() string { - if err.err != nil { - return fmt.Sprintf("Invalid Configuration Argument: %v: Error: %v", err.cfg, err.err) - } - return fmt.Sprintf("Invalid Configuration Argument: %v", err.cfg) -} - -// IsErrInvalidConfiguration checks if an error is an ErrInvalidConfiguration -func IsErrInvalidConfiguration(err error) bool { - _, ok := err.(ErrInvalidConfiguration) - return ok -} - type Type = setting.StorageType // NewStorageFunc is a function that creates a storage diff --git a/modules/structs/notifications.go b/modules/structs/notifications.go index d7aa0783dc2..b94e02aee35 100644 --- a/modules/structs/notifications.go +++ b/modules/structs/notifications.go @@ -68,12 +68,12 @@ const ( type NotifySubjectType string const ( - // NotifySubjectIssue an issue is subject of an notification + // NotifySubjectIssue a issue is subject of an notification NotifySubjectIssue NotifySubjectType = "Issue" - // NotifySubjectPull an pull is subject of an notification + // NotifySubjectPull a pull is subject of an notification NotifySubjectPull NotifySubjectType = "Pull" - // NotifySubjectCommit an commit is subject of an notification + // NotifySubjectCommit a commit is subject of an notification NotifySubjectCommit NotifySubjectType = "Commit" - // NotifySubjectRepository an repository is subject of an notification + // NotifySubjectRepository a repository is subject of an notification NotifySubjectRepository NotifySubjectType = "Repository" ) diff --git a/modules/structs/pull_review.go b/modules/structs/pull_review.go index de0677efabd..82c86b5b4ac 100644 --- a/modules/structs/pull_review.go +++ b/modules/structs/pull_review.go @@ -94,6 +94,11 @@ type CreatePullReviewComment struct { NewLineNum int64 `json:"new_position"` } +// CreatePullReviewCommentReplyOptions are options to reply to a pull request review comment +type CreatePullReviewCommentReplyOptions struct { + Body string `json:"body" binding:"Required"` +} + // SubmitPullReviewOptions are options to submit a pending pull request review type SubmitPullReviewOptions struct { Event ReviewStateType `json:"event"` diff --git a/modules/structs/repo_actions.go b/modules/structs/repo_actions.go index 92ca9bcccef..4592c18ed69 100644 --- a/modules/structs/repo_actions.go +++ b/modules/structs/repo_actions.go @@ -105,12 +105,18 @@ type ActionArtifact struct { // ActionWorkflowRun represents a WorkflowRun type ActionWorkflowRun struct { - ID int64 `json:"id"` - URL string `json:"url"` - HTMLURL string `json:"html_url"` - DisplayTitle string `json:"display_title"` - Path string `json:"path"` - Event string `json:"event"` + ID int64 `json:"id"` + URL string `json:"url"` + // PreviousAttemptURL is the API URL of the previous attempt of this run, e.g. ".../actions/runs/{run_id}/attempts/{attempt-1}". + // It is set only when the current attempt is > 1 (i.e. a rerun). For the first attempt, or for legacy runs that pre-date ActionRunAttempt, it is null. + PreviousAttemptURL *string `json:"previous_attempt_url"` + HTMLURL string `json:"html_url"` + DisplayTitle string `json:"display_title"` + Path string `json:"path"` + Event string `json:"event"` + // RunAttempt is 1-based for runs created after ActionRunAttempt was introduced. + // A value of 0 is a legacy-only sentinel for runs created before attempts existed + // and indicates no corresponding /attempts/{n} resource is available. RunAttempt int64 `json:"run_attempt"` RunNumber int64 `json:"run_number"` RepositoryID int64 `json:"repository_id,omitempty"` diff --git a/modules/templates/helper.go b/modules/templates/helper.go index f81be1255ab..4cd6269eaf8 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -6,12 +6,10 @@ package templates import ( "fmt" - "html" "html/template" "net/url" "strconv" "strings" - "sync" "time" "code.gitea.io/gitea/modules/base" @@ -32,13 +30,12 @@ func newFuncMapWebPage() template.FuncMap { // ----------------------------------------------------------------- // html/template related functions - "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names. - "Iif": iif, - "Eval": evalTokens, - "HTMLFormat": htmlFormat, - "QueryEscape": queryEscape, - "QueryBuild": QueryBuild, - "SanitizeHTML": SanitizeHTML, + "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names. + "Iif": iif, + "Eval": evalTokens, + "HTMLFormat": htmlFormat, + "QueryEscape": queryEscape, + "QueryBuild": QueryBuild, "PathEscape": url.PathEscape, "PathEscapeSegments": util.PathEscapeSegments, @@ -70,8 +67,7 @@ func newFuncMapWebPage() template.FuncMap { return strconv.FormatInt(time.Since(startTime).Nanoseconds()/1e6, 10) + "ms" }, - "AssetURI": public.AssetURI, - "ScriptImport": scriptImport, + "AssetURI": public.AssetURI, // ----------------------------------------------------------------- // setting @@ -146,9 +142,8 @@ func newFuncMapWebPage() template.FuncMap { } } -// SanitizeHTML sanitizes the input by default sanitization rules. -func SanitizeHTML(s string) template.HTML { - return markup.Sanitize(s) +func sanitizeHTML(msg string) template.HTML { + return markup.Sanitize(msg) } func htmlFormat(s any, args ...any) template.HTML { @@ -292,30 +287,3 @@ func QueryBuild(a ...any) template.URL { } return template.URL(s) } - -var globalVars = sync.OnceValue(func() (ret struct { - scriptImportRemainingPart string -}, -) { - // add onerror handler to alert users when the script fails to load: - // * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config - // * for developers: help them to remember to run "make watch-frontend" to build frontend assets - // the message will be directly put in the onerror JS code's string - onScriptErrorPrompt := `Please make sure the asset files can be accessed.` - if !setting.IsProd { - onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.` - } - onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt) - ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `">` - return ret -}) - -func scriptImport(path string, typ ...string) template.HTML { - if len(typ) > 0 { - if typ[0] == "module" { - return template.HTML(`", + "Summary": "summary with details", + "Details": "details line 1\n details line 2\n details line 3", + }) + msgWithSummary, _ := ctx.RenderToHTML("base/alert_details", map[string]any{ + "Message": "message with summary ", + "Summary": "summary only", + }) + + ctx.Flash.ErrorMsg = string(msgWithDetails) + ctx.Flash.WarningMsg = string(msgWithSummary) + ctx.Flash.InfoMsg = "a long message with line break\nthe second line " + ctx.Flash.SuccessMsg = "single line message " + ctx.Data["Flash"] = ctx.Flash +} + func prepareMockDataUnicodeEscape(ctx *context.Context) { content := "// demo code\n" content += "if accessLevel != \"user\u202E \u2066// Check if admin (invisible char)\u2069 \u2066\" { }\n" @@ -221,10 +241,9 @@ func prepareMockDataUnicodeEscape(ctx *context.Context) { func TmplCommon(ctx *context.Context) { prepareMockData(ctx) - if ctx.Req.Method == http.MethodPost { - _ = ctx.Req.ParseForm() - ctx.Flash.Info("form: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"
"+ - "Form: "+ctx.Req.Form.Encode()+"
"+ + if ctx.Req.Method == http.MethodPost && ctx.FormBool("mock_response_delay") { + ctx.Flash.Info("form submit: "+ctx.Req.Method+" "+ctx.Req.RequestURI+"\n"+ + "Form: "+ctx.Req.Form.Encode()+"\n"+ "PostForm: "+ctx.Req.PostForm.Encode(), true, ) diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 0fb2a358243..83e9bef9c8e 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -4,6 +4,7 @@ package devtest import ( + "fmt" mathRand "math/rand/v2" "net/http" "slices" @@ -12,7 +13,9 @@ import ( "time" actions_model "code.gitea.io/gitea/models/actions" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/repo/actions" @@ -36,6 +39,10 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt "##[group]test group for: step={step}, cursor={cursor}", "in group msg for: step={step}, cursor={cursor}", "##[endgroup]", + "::error::mock error for: step={step}, cursor={cursor}", + "::warning::mock warning for: step={step}, cursor={cursor}", + "::notice::mock notice for: step={step}, cursor={cursor}", + "::debug::mock debug for: step={step}, cursor={cursor}", ) // usually the cursor is the "file offset", but here we abuse it as "line number" to make the mock easier, intentionally cur := logCur.Cursor @@ -59,28 +66,29 @@ func generateMockStepsLog(logCur actions.LogCursor, opts generateMockStepsLogOpt } func MockActionsView(ctx *context.Context) { - ctx.Data["RunID"] = ctx.PathParamInt64("run") + if runID := ctx.PathParamInt64("run"); runID == 0 { + ctx.Redirect("/repo-action-view/runs/10") + return + } ctx.Data["JobID"] = ctx.PathParamInt64("job") + ctx.Data["ActionsViewURL"] = ctx.Req.URL.Path ctx.HTML(http.StatusOK, "devtest/repo-action-view") } func MockActionsRunsJobs(ctx *context.Context) { runID := ctx.PathParamInt64("run") + attemptID := ctx.PathParamInt64("attempt") + alignTime := func(v, unit int64) int64 { + return (v + unit) / unit * unit + } resp := &actions.ViewResponse{} resp.State.Run.RepoID = 12345 resp.State.Run.TitleHTML = `mock run title link` resp.State.Run.Link = setting.AppSubURL + "/devtest/repo-action-view/runs/" + strconv.FormatInt(runID, 10) - resp.State.Run.Status = actions_model.StatusRunning.String() - resp.State.Run.CanCancel = runID == 10 - resp.State.Run.CanApprove = runID == 20 - resp.State.Run.CanRerun = runID == 30 - resp.State.Run.CanRerunFailed = runID == 30 resp.State.Run.CanDeleteArtifact = true resp.State.Run.WorkflowID = "workflow-id" resp.State.Run.WorkflowLink = "./workflow-link" - resp.State.Run.Duration = "1h 23m 45s" - resp.State.Run.TriggeredAt = time.Now().Add(-time.Hour).Unix() resp.State.Run.TriggerEvent = "push" resp.State.Run.Commit = actions.ViewCommit{ ShortSha: "ccccdddd", @@ -95,37 +103,129 @@ func MockActionsRunsJobs(ctx *context.Context) { IsDeleted: false, }, } + now := time.Now() + currentAttemptNum := int64(1) + if attemptID > 0 { + currentAttemptNum = attemptID + } + user2 := &user_model.User{Name: "user2"} + user3 := &user_model.User{Name: "user3"} + attempts := []*actions_model.ActionRunAttempt{{ + Attempt: 1, + Status: actions_model.StatusSuccess, + Created: timeutil.TimeStamp(now.Add(-time.Hour).Unix()), + TriggerUserID: 2, + TriggerUser: user2, + }} + if runID == 10 { + attempts = []*actions_model.ActionRunAttempt{ + { + Attempt: 3, + Status: actions_model.StatusSuccess, + Created: timeutil.TimeStamp(alignTime(now.Add(-time.Hour).Unix(), 3600)), + TriggerUserID: 2, + TriggerUser: user2, + }, + { + Attempt: 2, + Status: actions_model.StatusFailure, + Created: timeutil.TimeStamp(alignTime(now.Add(-2*time.Hour).Unix(), 3600)), + TriggerUserID: 1, + TriggerUser: user3, + }, + { + Attempt: 1, + Status: actions_model.StatusSuccess, + Created: timeutil.TimeStamp(alignTime(now.Add(-3*time.Hour).Unix(), 3600)), + TriggerUserID: 2, + TriggerUser: user2, + }, + } + if attemptID == 0 { + currentAttemptNum = 3 + } + } + + latestAttempt := attempts[0] + resp.State.Run.RunAttempt = currentAttemptNum + resp.State.Run.Done = latestAttempt.Status.IsDone() + resp.State.Run.Status = latestAttempt.Status.String() + resp.State.Run.Duration = "1h 23m 45s" + resp.State.Run.TriggeredAt = latestAttempt.Created.AsTime().Unix() + resp.State.Run.ViewLink = resp.State.Run.Link + for _, attempt := range attempts { + link := resp.State.Run.Link + if attempt.Attempt != latestAttempt.Attempt { + link = fmt.Sprintf("%s/attempts/%d", resp.State.Run.Link, attempt.Attempt) + } + current := attempt.Attempt == currentAttemptNum + if current { + resp.State.Run.Status = attempt.Status.String() + resp.State.Run.Done = attempt.Status.IsDone() + resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix() + if attempt.Attempt != latestAttempt.Attempt { + resp.State.Run.ViewLink = link + } + } + resp.State.Run.Attempts = append(resp.State.Run.Attempts, &actions.ViewRunAttempt{ + Attempt: attempt.Attempt, + Status: attempt.Status.String(), + Done: attempt.Status.IsDone(), + Link: link, + Current: current, + Latest: attempt.Attempt == latestAttempt.Attempt, + TriggeredAt: attempt.Created.AsTime().Unix(), + TriggerUserName: attempt.TriggerUser.GetDisplayName(), + TriggerUserLink: attempt.TriggerUser.HomeLink(), + }) + } + isLatestAttempt := currentAttemptNum == latestAttempt.Attempt + resp.State.Run.CanCancel = runID == 10 && isLatestAttempt + resp.State.Run.CanApprove = runID == 20 && isLatestAttempt + resp.State.Run.CanRerun = runID == 30 && isLatestAttempt + resp.State.Run.CanRerunFailed = runID == 30 && isLatestAttempt + resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-a", - Size: 100 * 1024, - Status: "expired", + Name: "artifact-a", + Size: 100 * 1024, + Status: "expired", + ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-b", - Size: 1024 * 1024, - Status: "completed", + Name: "artifact-b", + Size: 1024 * 1024, + Status: "completed", + ExpiresUnix: alignTime(time.Now().Add(24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", - Size: 100 * 1024, - Status: "expired", + Name: "artifact-very-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Size: 100 * 1024, + Status: "expired", + ExpiresUnix: alignTime(time.Now().Add(-24*time.Hour).Unix(), 3600), }) resp.Artifacts = append(resp.Artifacts, &actions.ArtifactsViewItem{ - Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", - Size: 1024 * 1024, - Status: "completed", + Name: "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong", + Size: 1024 * 1024, + Status: "completed", + ExpiresUnix: 0, }) + jobLink := func(jobID int64) string { + return fmt.Sprintf("%s/jobs/%d", resp.State.Run.Link, jobID) + } + resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID * 10, + Link: jobLink(runID * 10), JobID: "job-100", - Name: "job 100", + Name: "job 100 (testsubname)", Status: actions_model.StatusRunning.String(), CanRerun: true, - Duration: "1h", + Duration: "1h23m45s", }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID*10 + 1, + Link: jobLink(runID*10 + 1), JobID: "job-101", Name: "job 101", Status: actions_model.StatusWaiting.String(), @@ -135,6 +235,7 @@ func MockActionsRunsJobs(ctx *context.Context) { }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID*10 + 2, + Link: jobLink(runID*10 + 2), JobID: "job-102", Name: "ULTRA LOOOOOOOOOOOONG job name 102 that exceeds the limit", Status: actions_model.StatusFailure.String(), @@ -144,6 +245,7 @@ func MockActionsRunsJobs(ctx *context.Context) { }) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ ID: runID*10 + 3, + Link: jobLink(runID*10 + 3), JobID: "job-103", Name: "job 103", Status: actions_model.StatusCancelled.String(), @@ -155,8 +257,10 @@ func MockActionsRunsJobs(ctx *context.Context) { // add more jobs to a run for UI testing if resp.State.Run.CanCancel { for i := range 10 { + jobID := runID*1000 + int64(i) resp.State.Run.Jobs = append(resp.State.Run.Jobs, &actions.ViewJob{ - ID: runID*1000 + int64(i), + ID: jobID, + Link: jobLink(jobID), JobID: "job-dup-test-" + strconv.Itoa(i), Name: "job dup test " + strconv.Itoa(i), Status: actions_model.StatusSuccess.String(), @@ -177,6 +281,14 @@ func fillViewRunResponseCurrentJob(ctx *context.Context, resp *actions.ViewRespo return } + for _, job := range resp.State.Run.Jobs { + if job.ID == jobID { + resp.State.CurrentJob.Title = job.Name + resp.State.CurrentJob.Detail = job.Status + break + } + } + req := web.GetForm(ctx).(*actions.ViewRequest) var mockLogOptions []generateMockStepsLogOptions resp.State.CurrentJob.Steps = append(resp.State.CurrentJob.Steps, &actions.ViewJobStep{ diff --git a/routers/web/feed/convert.go b/routers/web/feed/convert.go index a5c379e01a9..5d208bb2861 100644 --- a/routers/web/feed/convert.go +++ b/routers/web/feed/convert.go @@ -15,6 +15,7 @@ import ( activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/renderhelper" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" @@ -237,7 +238,7 @@ func feedActionsToFeedItems(ctx *context.Context, actions activities_model.Actio } } if len(content) == 0 { - content = templates.SanitizeHTML(desc) + content = markup.Sanitize(desc) } items = append(items, &feeds.Item{ diff --git a/routers/web/home.go b/routers/web/home.go index 7efa5f344e3..d14a7bab13f 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -109,9 +109,3 @@ func HomeSitemap(ctx *context.Context) { log.Error("Failed writing sitemap: %v", err) } } - -// NotFound render 404 page -func NotFound(ctx *context.Context) { - ctx.Data["Title"] = "Page Not Found" - ctx.NotFound(nil) -} diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index a50d9130ac2..0b939ee4352 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -7,9 +7,12 @@ import ( "net/http" "path" "strconv" + "strings" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -17,6 +20,29 @@ import ( "code.gitea.io/gitea/services/context" ) +func SiteManifest(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/manifest+json") + if httpcache.HandleGenericETagPublicCache(req, w, "", &setting.AppStartTime) { + return + } + if req.Method == http.MethodHead { + return + } + + ctx := req.Context() + absoluteAssetURL := strings.TrimSuffix(httplib.MakeAbsoluteURL(ctx, setting.StaticURLPrefix), "/") + manifest := map[string]any{ + "name": setting.AppName, + "short_name": setting.AppName, + "start_url": httplib.GuessCurrentAppURL(ctx), + "icons": []map[string]string{ + {"src": absoluteAssetURL + "/assets/img/logo.png", "type": "image/png", "sizes": "512x512"}, + {"src": absoluteAssetURL + "/assets/img/logo.svg", "type": "image/svg+xml", "sizes": "512x512"}, + }, + } + _ = json.NewEncoder(w).Encode(manifest) +} + func SSHInfo(rw http.ResponseWriter, req *http.Request) { if !git.DefaultFeatures().SupportProcReceive { rw.WriteHeader(http.StatusNotFound) diff --git a/routers/web/org/block.go b/routers/web/org/block.go index 60f722dd392..e728e4dce5a 100644 --- a/routers/web/org/block.go +++ b/routers/web/org/block.go @@ -34,15 +34,5 @@ func BlockedUsers(ctx *context.Context) { } func BlockedUsersPost(ctx *context.Context) { - if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { - ctx.ServerError("RenderUserOrgHeader", err) - return - } - - shared_user.BlockedUsersPost(ctx, ctx.ContextUser) - if ctx.Written() { - return - } - - ctx.Redirect(ctx.ContextUser.OrganisationLink() + "/settings/blocked_users") + shared_user.BlockedUsersPost(ctx, ctx.ContextUser, ctx.ContextUser.OrganisationLink()+"/settings/blocked_users") } diff --git a/routers/web/org/home.go b/routers/web/org/home.go index e18a8de40f6..262b001e6a2 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -98,8 +98,10 @@ func home(ctx *context.Context, viewRepositories bool) { ctx.ServerError("FindOrgMembers", err) return } - ctx.Data["Members"] = members - ctx.Data["Teams"] = ctx.Org.Teams + + const orgOverviewTeamsLimit = 5 + ctx.Data["OrgOverviewMembers"] = members + ctx.Data["OrgOverviewTeams"] = ctx.Org.Teams[:min(len(ctx.Org.Teams), orgOverviewTeamsLimit)] ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull ctx.Data["ShowMemberAndTeamTab"] = ctx.Org.IsMember || len(members) > 0 diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 4cdf81c1559..ae32be05757 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -11,7 +11,6 @@ import ( "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" - org_model "code.gitea.io/gitea/models/organization" project_model "code.gitea.io/gitea/models/project" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" @@ -35,14 +34,6 @@ const ( tplProjectsView templates.TplName = "org/projects/view" ) -// MustEnableProjects check if projects are enabled in settings -func MustEnableProjects(ctx *context.Context) { - if unit.TypeProjects.UnitGlobalDisabled() { - ctx.NotFound(nil) - return - } -} - // Projects renders the home page of projects func Projects(ctx *context.Context) { if _, err := shared_user.RenderUserOrgHeader(ctx); err != nil { @@ -459,9 +450,9 @@ func ViewProject(ctx *context.Context) { ctx.Data["MilestoneID"] = milestoneID // Get assignees. - assigneeUsers, err := org_model.GetOrgAssignees(ctx, project.OwnerID) + assigneeUsers, err := project_service.LoadIssuesAssigneesForProject(ctx, issuesMap) if err != nil { - ctx.ServerError("GetRepoAssignees", err) + ctx.ServerError("LoadIssuesAssigneesForProject", err) return } ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 1e22a670320..10803c9fbf5 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" @@ -54,13 +55,54 @@ func Teams(ctx *context.Context) { ctx.Data["Title"] = org.FullName ctx.Data["PageIsOrgTeams"] = true - for _, t := range ctx.Org.Teams { + keyword := ctx.FormTrim("q") + page := max(ctx.FormInt("page"), 1) + pagingNum := setting.UI.MembersPagingNum + + searchTeams := func() (teams []*org_model.Team, count int64, err error) { + if keyword == "" { + // fast path, use existing teams in context if no need to filter from database + count = int64(len(ctx.Org.Teams)) + start := (page - 1) * pagingNum + if start > len(ctx.Org.Teams) { + return nil, count, nil + } + end := min(start+pagingNum, len(ctx.Org.Teams)) + return ctx.Org.Teams[start:end], count, nil + } + + shouldSeeAllOrgTeams, err := context.UserShouldSeeAllOrgTeams(ctx) + if err != nil { + return nil, 0, err + } + opts := &org_model.SearchTeamOptions{ + OrgID: org.ID, + UserID: util.Iif(shouldSeeAllOrgTeams, 0, ctx.Doer.ID), + Keyword: keyword, + IncludeDesc: true, + ListOptions: db.ListOptions{Page: page, PageSize: pagingNum}, + } + return org_model.SearchTeam(ctx, opts) + } + + teams, count, err := searchTeams() + if err != nil { + ctx.ServerError("SearchTeam", err) + return + } + + for _, t := range teams { if err := t.LoadMembers(ctx); err != nil { ctx.ServerError("GetMembers", err) return } } - ctx.Data["Teams"] = ctx.Org.Teams + + ctx.Data["OrgListTeams"] = teams + ctx.Data["Keyword"] = keyword + pager := context.NewPagination(count, setting.UI.MembersPagingNum, page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager ctx.HTML(http.StatusOK, tplTeams) } @@ -213,7 +255,7 @@ func checkIsOrgMemberAndRedirect(ctx *context.Context, defaultRedirect string) { if isOrgMember, err := org_model.IsOrganizationMember(ctx, ctx.Org.Organization.ID, ctx.Doer.ID); err != nil { ctx.ServerError("IsOrganizationMember", err) return - } else if !isOrgMember { + } else if !isOrgMember && !ctx.Doer.IsAdmin { if ctx.Org.Organization.Visibility.IsPrivate() { defaultRedirect = setting.AppSubURL + "/" } else { diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 988d2d0a993..1e9f596fc40 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -57,7 +57,7 @@ func MustEnableActions(ctx *context.Context) { } if ctx.Repo.Repository != nil { - if !ctx.Repo.CanRead(unit.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { ctx.NotFound(nil) return } @@ -151,6 +151,11 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow workflows = append(workflows, workflow) continue } + if err := actions.ValidateWorkflowContent(content); err != nil { + workflow.ErrMsg = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + workflows = append(workflows, workflow) + continue + } workflow.Workflow = wf // The workflow must contain at least one job without "needs". Otherwise, a deadlock will occur and no jobs will be able to run. hasJobWithoutNeeds := false @@ -176,7 +181,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow ctx.Data["workflows"] = workflows ctx.Data["RepoLink"] = ctx.Repo.Repository.Link() - ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.IsAdmin() + ctx.Data["AllowDisableOrEnableWorkflow"] = ctx.Repo.Permission.IsAdmin() actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() ctx.Data["ActionsConfig"] = actionsConfig ctx.Data["CurWorkflow"] = curWorkflowID @@ -187,7 +192,7 @@ func prepareWorkflowTemplate(ctx *context.Context, commit *git.Commit) (workflow func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []WorkflowInfo, curWorkflowID string) { actionsConfig := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeActions).ActionsConfig() - if curWorkflowID == "" || !ctx.Repo.CanWrite(unit.TypeActions) || actionsConfig.IsWorkflowDisabled(curWorkflowID) { + if curWorkflowID == "" || !ctx.Repo.Permission.CanWrite(unit.TypeActions) || actionsConfig.IsWorkflowDisabled(curWorkflowID) { return } @@ -306,7 +311,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) { if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning) { continue } - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { ctx.ServerError("GetRunJobsByRunID", err) return @@ -315,6 +320,10 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) { if !job.Status.IsWaiting() { continue } + if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil { + runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + break + } hasOnlineRunner := false for _, runner := range runners { if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) { @@ -346,7 +355,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo) { ctx.Data["Page"] = pager ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(runs) > 0 - ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.CanWrite(unit.TypeActions) + ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions) } // loadIsRefDeleted loads the IsRefDeleted field for each run in the list. diff --git a/routers/web/repo/actions/view.go b/routers/web/repo/actions/view.go index f92df685fda..e17d6b42d7a 100644 --- a/routers/web/repo/actions/view.go +++ b/routers/web/repo/actions/view.go @@ -34,7 +34,6 @@ import ( "code.gitea.io/gitea/routers/common" actions_service "code.gitea.io/gitea/services/actions" context_module "code.gitea.io/gitea/services/context" - notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/model" ) @@ -166,7 +165,7 @@ func resolveCurrentRunForView(ctx *context_module.Context) *actions_model.Action return nil } if run != nil { - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { ctx.ServerError("GetRunJobsByRunID", err) return nil @@ -203,9 +202,23 @@ func View(ctx *context_module.Context) { if ctx.Written() { return } - ctx.Data["RunID"] = run.ID - ctx.Data["JobID"] = ctx.PathParamInt64("job") // it can be 0 when no job (e.g.: run summary view) - ctx.Data["ActionsURL"] = ctx.Repo.RepoLink + "/actions" + run.Repo = ctx.Repo.Repository + + jobID := ctx.PathParamInt64("job") + ctx.Data["JobID"] = jobID // it can be 0 when no job (e.g.: run summary view) + + attemptNum := ctx.PathParamInt64("attempt") + + // ActionsViewURL is the endpoint for viewing a run (job summary), a job, or a job attempt. + // It's POST method handler can provide the state data for the frontend rendering. + switch { + case attemptNum > 0: + ctx.Data["ActionsViewURL"] = fmt.Sprintf("%s/attempts/%d", run.Link(), attemptNum) + case jobID > 0: + ctx.Data["ActionsViewURL"] = fmt.Sprintf("%s/jobs/%d", run.Link(), jobID) + default: + ctx.Data["ActionsViewURL"] = run.Link() + } ctx.HTML(http.StatusOK, tplViewActions) } @@ -248,9 +261,10 @@ type ViewRequest struct { } type ArtifactsViewItem struct { - Name string `json:"name"` - Size int64 `json:"size"` - Status string `json:"status"` + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` + ExpiresUnix int64 `json:"expiresUnix"` } type ViewResponse struct { @@ -258,22 +272,30 @@ type ViewResponse struct { State struct { Run struct { - RepoID int64 `json:"repoId"` - Link string `json:"link"` - Title string `json:"title"` - TitleHTML template.HTML `json:"titleHTML"` - Status string `json:"status"` - CanCancel bool `json:"canCancel"` - CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve - CanRerun bool `json:"canRerun"` - CanRerunFailed bool `json:"canRerunFailed"` - CanDeleteArtifact bool `json:"canDeleteArtifact"` - Done bool `json:"done"` - WorkflowID string `json:"workflowID"` - WorkflowLink string `json:"workflowLink"` - IsSchedule bool `json:"isSchedule"` - Jobs []*ViewJob `json:"jobs"` - Commit ViewCommit `json:"commit"` + RepoID int64 `json:"repoId"` + // Link is the canonical HTML URL of the run, e.g. "/owner/repo/actions/runs/123". + // Used as the base for composing sub-resource URLs (cancel, rerun, artifacts, jobs) that are not attempt-scoped. + Link string `json:"link"` + // ViewLink is the attempt-aware URL for navigation, e.g. "/owner/repo/actions/runs/123" for the latest attempt + // or "/owner/repo/actions/runs/123/attempts/2" for a historical attempt. + // Use this when the target should reflect the currently-viewed attempt. + ViewLink string `json:"viewLink"` + Title string `json:"title"` + TitleHTML template.HTML `json:"titleHTML"` + Status string `json:"status"` + CanCancel bool `json:"canCancel"` + CanApprove bool `json:"canApprove"` // the run needs an approval and the doer has permission to approve + CanRerun bool `json:"canRerun"` + CanRerunFailed bool `json:"canRerunFailed"` + CanDeleteArtifact bool `json:"canDeleteArtifact"` + Done bool `json:"done"` + WorkflowID string `json:"workflowID"` + WorkflowLink string `json:"workflowLink"` + IsSchedule bool `json:"isSchedule"` + RunAttempt int64 `json:"runAttempt"` + Attempts []*ViewRunAttempt `json:"attempts"` + Jobs []*ViewJob `json:"jobs"` + Commit ViewCommit `json:"commit"` // Summary view: run duration and trigger time/event Duration string `json:"duration"` TriggeredAt int64 `json:"triggeredAt"` // unix seconds for relative time @@ -292,6 +314,7 @@ type ViewResponse struct { type ViewJob struct { ID int64 `json:"id"` + Link string `json:"link"` JobID string `json:"jobId,omitempty"` Name string `json:"name"` Status string `json:"status"` @@ -300,6 +323,18 @@ type ViewJob struct { Needs []string `json:"needs,omitempty"` } +type ViewRunAttempt struct { + Attempt int64 `json:"attempt"` + Status string `json:"status"` + Done bool `json:"done"` + Link string `json:"link"` + Current bool `json:"current"` + Latest bool `json:"latest"` + TriggeredAt int64 `json:"triggeredAt"` + TriggerUserName string `json:"triggerUserName"` + TriggerUserLink string `json:"triggerUserLink"` +} + type ViewCommit struct { ShortSha string `json:"shortSHA"` Link string `json:"link"` @@ -337,23 +372,8 @@ type ViewStepLogLine struct { Timestamp float64 `json:"timestamp"` } -func getActionsViewArtifacts(ctx context.Context, repoID, runID int64) (artifactsViewItems []*ArtifactsViewItem, err error) { - artifacts, err := actions_model.ListUploadedArtifactsMeta(ctx, repoID, runID) - if err != nil { - return nil, err - } - for _, art := range artifacts { - artifactsViewItems = append(artifactsViewItems, &ArtifactsViewItem{ - Name: art.ArtifactName, - Size: art.FileSize, - Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), - }) - } - return artifactsViewItems, nil -} - func ViewPost(ctx *context_module.Context) { - run, jobs := getCurrentRunJobsByPathParam(ctx) + run, attempt, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } @@ -363,7 +383,7 @@ func ViewPost(ctx *context_module.Context) { } resp := &ViewResponse{} - fillViewRunResponseSummary(ctx, resp, run, jobs) + fillViewRunResponseSummary(ctx, resp, run, attempt, jobs) if ctx.Written() { return } @@ -374,23 +394,33 @@ func ViewPost(ctx *context_module.Context) { ctx.JSON(http.StatusOK, resp) } -func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) { - var err error - resp.Artifacts, err = getActionsViewArtifacts(ctx, ctx.Repo.Repository.ID, run.ID) - if err != nil { - ctx.ServerError("getActionsViewArtifacts", err) - return - } +func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, jobs []*actions_model.ActionRunJob) { + // Latest when the run has no attempts yet (legacy) or the viewed attempt is the run's latest. + isLatestAttempt := run.LatestAttemptID == 0 || (attempt != nil && attempt.ID == run.LatestAttemptID) resp.State.Run.RepoID = ctx.Repo.Repository.ID // the title for the "run" is from the commit message resp.State.Run.Title = run.Title resp.State.Run.TitleHTML = templates.NewRenderUtils(ctx).RenderCommitMessage(run.Title, ctx.Repo.Repository) resp.State.Run.Link = run.Link() - resp.State.Run.CanCancel = !run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) - resp.State.Run.CanApprove = run.NeedApproval && ctx.Repo.CanWrite(unit.TypeActions) - resp.State.Run.CanRerun = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) - resp.State.Run.CanDeleteArtifact = run.Status.IsDone() && ctx.Repo.CanWrite(unit.TypeActions) + resp.State.Run.ViewLink = getRunViewLink(run, attempt) + resp.State.Run.Attempts = make([]*ViewRunAttempt, 0) + if attempt != nil { + resp.State.Run.RunAttempt = attempt.Attempt + resp.State.Run.Status = attempt.Status.String() + resp.State.Run.Done = attempt.Status.IsDone() + resp.State.Run.Duration = attempt.Duration().String() + resp.State.Run.TriggeredAt = attempt.Created.AsTime().Unix() + } else { + resp.State.Run.Status = run.Status.String() + resp.State.Run.Done = run.Status.IsDone() + resp.State.Run.Duration = run.Duration().String() + resp.State.Run.TriggeredAt = run.Created.AsTime().Unix() + } + resp.State.Run.CanCancel = isLatestAttempt && !resp.State.Run.Done && ctx.Repo.Permission.CanWrite(unit.TypeActions) + resp.State.Run.CanApprove = isLatestAttempt && run.NeedApproval && ctx.Repo.Permission.CanWrite(unit.TypeActions) + resp.State.Run.CanRerun = isLatestAttempt && resp.State.Run.Done && ctx.Repo.Permission.CanWrite(unit.TypeActions) + resp.State.Run.CanDeleteArtifact = resp.State.Run.Done && ctx.Repo.Permission.CanWrite(unit.TypeActions) if resp.State.Run.CanRerun { for _, job := range jobs { if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled { @@ -399,15 +429,16 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, } } } - resp.State.Run.Done = run.Status.IsDone() resp.State.Run.WorkflowID = run.WorkflowID - resp.State.Run.WorkflowLink = run.WorkflowLink() + if isLatestAttempt { + resp.State.Run.WorkflowLink = run.WorkflowLink() + } resp.State.Run.IsSchedule = run.IsSchedule() resp.State.Run.Jobs = make([]*ViewJob, 0, len(jobs)) // marshal to '[]' instead fo 'null' in json - resp.State.Run.Status = run.Status.String() for _, v := range jobs { resp.State.Run.Jobs = append(resp.State.Run.Jobs, &ViewJob{ ID: v.ID, + Link: fmt.Sprintf("%s/jobs/%d", run.Link(), v.ID), JobID: v.JobID, Name: v.Name, Status: v.Status.String(), @@ -417,6 +448,29 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, }) } + attempts, err := actions_model.ListRunAttemptsByRunID(ctx, run.ID) + if err != nil { + ctx.ServerError("ListRunAttemptsByRunID", err) + return + } + if err := attempts.LoadTriggerUser(ctx); err != nil { + ctx.ServerError("LoadTriggerUser", err) + return + } + for _, runAttempt := range attempts { + resp.State.Run.Attempts = append(resp.State.Run.Attempts, &ViewRunAttempt{ + Attempt: runAttempt.Attempt, + Status: runAttempt.Status.String(), + Done: runAttempt.Status.IsDone(), + Link: getRunViewLink(run, runAttempt), + Current: runAttempt.ID == attempt.ID, + Latest: runAttempt.ID == run.LatestAttemptID, + TriggeredAt: runAttempt.Created.AsTime().Unix(), + TriggerUserName: runAttempt.TriggerUser.GetDisplayName(), + TriggerUserLink: runAttempt.TriggerUser.HomeLink(), + }) + } + pusher := ViewUser{ DisplayName: run.TriggerUser.GetDisplayName(), Link: run.TriggerUser.HomeLink(), @@ -441,9 +495,27 @@ func fillViewRunResponseSummary(ctx *context_module.Context, resp *ViewResponse, Pusher: pusher, Branch: branch, } - resp.State.Run.Duration = run.Duration().String() - resp.State.Run.TriggeredAt = run.Created.AsTime().Unix() resp.State.Run.TriggerEvent = run.TriggerEvent + + // Legacy runs (LatestAttemptID == 0) have no attempt; their artifacts all share run_attempt_id=0, + // so passing 0 here scopes to this run's legacy artifacts only. + var runAttemptID int64 + if attempt != nil { + runAttemptID = attempt.ID + } + arts, err := actions_model.ListUploadedArtifactsMetaByRunAttempt(ctx, ctx.Repo.Repository.ID, run.ID, runAttemptID) + if err != nil { + ctx.ServerError("ListUploadedArtifactsMetaByRunAttempt", err) + return + } + resp.Artifacts = make([]*ArtifactsViewItem, 0, len(arts)) + for _, art := range arts { + resp.Artifacts = append(resp.Artifacts, &ArtifactsViewItem{ + Name: art.ArtifactName, + Size: art.FileSize, + Status: util.Iif(art.Status == actions_model.ArtifactStatusExpired, "expired", "completed"), + }) + } } func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewResponse, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) { @@ -457,9 +529,9 @@ func fillViewRunResponseCurrentJob(ctx *context_module.Context, resp *ViewRespon } var task *actions_model.ActionTask - if current.TaskID > 0 { + if effectiveTaskID := current.EffectiveTaskID(); effectiveTaskID > 0 { var err error - task, err = actions_model.GetTaskByID(ctx, current.TaskID) + task, err = actions_model.GetTaskByID(ctx, effectiveTaskID) if err != nil { ctx.ServerError("actions_model.GetTaskByID", err) return @@ -587,13 +659,24 @@ func checkRunRerunAllowed(ctx *context_module.Context, run *actions_model.Action return true } +func checkLatestAttempt(ctx *context_module.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) bool { + if attempt != nil && run.LatestAttemptID != attempt.ID { + ctx.NotFound(nil) + return false + } + return true +} + // Rerun will rerun jobs in the given run // If jobIDStr is a blank string, it means rerun all jobs func Rerun(ctx *context_module.Context) { - run, jobs := getCurrentRunJobsByPathParam(ctx) + run, attempt, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } + if !checkLatestAttempt(ctx, run, attempt) { + return + } if !checkRunRerunAllowed(ctx, run) { return } @@ -606,35 +689,48 @@ func Rerun(ctx *context_module.Context) { var jobsToRerun []*actions_model.ActionRunJob if currentJob != nil { - jobsToRerun = actions_service.GetAllRerunJobs(currentJob, jobs) - } else { - jobsToRerun = jobs + jobsToRerun = []*actions_model.ActionRunJob{currentJob} } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, jobsToRerun); err != nil { - ctx.ServerError("RerunWorkflowRunJobs", err) + if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, jobsToRerun); err != nil { + handleWorkflowRerunError(ctx, err) return } - ctx.JSONOK() + ctx.JSONRedirect(run.Link()) } // RerunFailed reruns all failed jobs in the given run func RerunFailed(ctx *context_module.Context) { - run, jobs := getCurrentRunJobsByPathParam(ctx) + run, attempt, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } + if !checkLatestAttempt(ctx, run, attempt) { + return + } if !checkRunRerunAllowed(ctx, run) { return } - if err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, actions_service.GetFailedRerunJobs(jobs)); err != nil { - ctx.ServerError("RerunWorkflowRunJobs", err) + if _, err := actions_service.RerunWorkflowRunJobs(ctx, ctx.Repo.Repository, run, ctx.Doer, actions_service.GetFailedJobsForRerun(jobs)); err != nil { + handleWorkflowRerunError(ctx, err) return } - ctx.JSONOK() + ctx.JSONRedirect(run.Link()) +} + +func handleWorkflowRerunError(ctx *context_module.Context, err error) { + if errors.Is(err, util.ErrAlreadyExist) { + ctx.JSON(http.StatusConflict, map[string]any{"message": err.Error()}) + return + } + if errors.Is(err, util.ErrInvalidArgument) { + ctx.JSON(http.StatusBadRequest, map[string]any{"message": err.Error()}) + return + } + ctx.ServerError("RerunWorkflowRunJobs", err) } func Logs(ctx *context_module.Context) { @@ -652,10 +748,13 @@ func Logs(ctx *context_module.Context) { } func Cancel(ctx *context_module.Context) { - run, jobs := getCurrentRunJobsByPathParam(ctx) + run, attempt, jobs := getCurrentRunJobsByPathParam(ctx) if ctx.Written() { return } + if !checkLatestAttempt(ctx, run, attempt) { + return + } var updatedJobs []*actions_model.ActionRunJob @@ -674,13 +773,9 @@ func Cancel(ctx *context_module.Context) { actions_service.CreateCommitStatusForRunJobs(ctx, run, jobs...) actions_service.EmitJobsIfReadyByJobs(updatedJobs) - for _, job := range updatedJobs { - _ = job.LoadAttributes(ctx) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - } + actions_service.NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) if len(updatedJobs) > 0 { - job := updatedJobs[0] - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, run.RepoID, run.ID) } ctx.JSONOK() } @@ -690,78 +785,14 @@ func Approve(ctx *context_module.Context) { if ctx.Written() { return } - approveRuns(ctx, []int64{run.ID}) - if ctx.Written() { - return - } - - ctx.JSONOK() -} - -func approveRuns(ctx *context_module.Context, runIDs []int64) { - doer := ctx.Doer - repo := ctx.Repo.Repository - - updatedJobs := make([]*actions_model.ActionRunJob, 0) - runMap := make(map[int64]*actions_model.ActionRun, len(runIDs)) - runJobs := make(map[int64][]*actions_model.ActionRunJob, len(runIDs)) - - err := db.WithTx(ctx, func(ctx context.Context) (err error) { - for _, runID := range runIDs { - run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID) - if err != nil { - return err - } - runMap[run.ID] = run - run.Repo = repo - run.NeedApproval = false - run.ApprovedBy = doer.ID - if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { - return err - } - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) - if err != nil { - return err - } - runJobs[run.ID] = jobs - for _, job := range jobs { - job.Status, err = actions_service.PrepareToStartJobWithConcurrency(ctx, job) - if err != nil { - return err - } - if job.Status == actions_model.StatusWaiting { - n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") - if err != nil { - return err - } - if n > 0 { - updatedJobs = append(updatedJobs, job) - } - } - } - } - return nil - }) - if err != nil { - ctx.NotFoundOrServerError("approveRuns", func(err error) bool { + if err := actions_service.ApproveRuns(ctx, ctx.Repo.Repository, ctx.Doer, []int64{run.ID}); err != nil { + ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool { return errors.Is(err, util.ErrNotExist) }, err) return } - for runID, run := range runMap { - actions_service.CreateCommitStatusForRunJobs(ctx, run, runJobs[runID]...) - } - - if len(updatedJobs) > 0 { - job := updatedJobs[0] - actions_service.NotifyWorkflowRunStatusUpdateWithReload(ctx, job) - } - - for _, job := range updatedJobs { - _ = job.LoadAttributes(ctx) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - } + ctx.JSONOK() } func Delete(ctx *context_module.Context) { @@ -783,28 +814,108 @@ func Delete(ctx *context_module.Context) { ctx.JSONOK() } -// getRunJobs loads the run and its jobs for runID +func getRunViewLink(run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt) string { + if attempt == nil || run.LatestAttemptID == attempt.ID { + return run.Link() + } + return fmt.Sprintf("%s/attempts/%d", run.Link(), attempt.Attempt) +} + +// getCurrentRunJobsByPathParam resolves the current run view context from path parameters, including the run, optional attempt, and jobs to render. // Any error will be written to the ctx, empty jobs will also result in 404 error, then the return values are all nil. -func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, []*actions_model.ActionRunJob) { +func getCurrentRunJobsByPathParam(ctx *context_module.Context) (*actions_model.ActionRun, *actions_model.ActionRunAttempt, []*actions_model.ActionRunJob) { run := getCurrentRunByPathParam(ctx) if ctx.Written() { - return nil, nil + return nil, nil, nil } run.Repo = ctx.Repo.Repository - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + + var err error + var selectedJob *actions_model.ActionRunJob + if ctx.PathParam("job") != "" { + jobID := ctx.PathParamInt64("job") + selectedJob, err = actions_model.GetRunJobByRunAndID(ctx, run.ID, jobID) + if err != nil { + ctx.NotFoundOrServerError("GetRunJobByRepoAndID", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return nil, nil, nil + } + } + + // Resolve the attempt to display. + // Priority: explicit path param (/attempts/:num) > job's attempt (when navigating to a specific job) > latest attempt. + // attempt may be nil for legacy runs that pre-date ActionRunAttempt; callers must handle that case. + attemptNum := ctx.PathParamInt64("attempt") + var attempt *actions_model.ActionRunAttempt + switch { + case attemptNum > 0: + // Explicit attempt number in the URL — user is viewing a historical attempt. + attempt, err = actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum) + if err != nil { + ctx.NotFoundOrServerError("GetRunAttemptByRunIDAndAttempt", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return nil, nil, nil + } + case selectedJob != nil && selectedJob.RunAttemptID > 0: + // No explicit attempt in the URL, but the requested job belongs to a known attempt — resolve via the job. + attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, selectedJob.RepoID, selectedJob.RunAttemptID) + if err != nil { + ctx.NotFoundOrServerError("GetRunAttemptByRepoAndID", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return nil, nil, nil + } + default: + // No attempt context at all — show the latest attempt (nil for legacy runs). + attempt, _, err = run.GetLatestAttempt(ctx) + if err != nil { + ctx.NotFoundOrServerError("GetLatestAttempt", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return nil, nil, nil + } + } + + // Resolve the jobs for the resolved attempt. + // When attempt is nil (legacy run or legacy job), jobs are stored with run_attempt_id=0. + var resolvedAttemptID int64 + if attempt != nil { + resolvedAttemptID = attempt.ID + } + jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, resolvedAttemptID) if err != nil { - ctx.ServerError("GetRunJobsByRunID", err) - return nil, nil + ctx.ServerError("get current jobs", err) + return nil, nil, nil } if len(jobs) == 0 { ctx.NotFound(nil) - return nil, nil + return nil, nil, nil } for _, job := range jobs { job.Run = run } - return run, jobs + return run, attempt, jobs +} + +// resolveArtifactAttemptIDFromQuery resolves the run_attempt_id used to scope artifact lookups. +// If the `attempt` query parameter is present and valid, it returns the matching attempt's ID. +// Otherwise it falls back to run.LatestAttemptID, which is 0 only for legacy runs created before ActionRunAttempt existed. +func resolveArtifactAttemptIDFromQuery(ctx *context_module.Context, run *actions_model.ActionRun) (int64, error) { + if ctx.FormString("attempt") == "" { + return run.LatestAttemptID, nil + } + attemptNum := ctx.FormInt64("attempt") + if attemptNum <= 0 { + return 0, util.ErrNotExist + } + attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum) + if err != nil { + return 0, err + } + return attempt.ID, nil } func ArtifactsDeleteView(ctx *context_module.Context) { @@ -812,9 +923,16 @@ func ArtifactsDeleteView(ctx *context_module.Context) { if ctx.Written() { return } + resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run) + if err != nil { + ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } artifactName := ctx.PathParam("artifact_name") - if err := actions_model.SetArtifactNeedDelete(ctx, run.ID, artifactName); err != nil { - ctx.ServerError("SetArtifactNeedDelete", err) + if err := actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, run.ID, resolvedAttemptID, artifactName); err != nil { + ctx.ServerError("SetArtifactNeedDeleteByRunAttempt", err) return } ctx.JSON(http.StatusOK, struct{}{}) @@ -825,14 +943,17 @@ func ArtifactsDownloadView(ctx *context_module.Context) { if ctx.Written() { return } - - artifactName := ctx.PathParam("artifact_name") - artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ - RunID: run.ID, - ArtifactName: artifactName, - }) + resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run) if err != nil { - ctx.ServerError("FindArtifacts", err) + ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + artifactName := ctx.PathParam("artifact_name") + artifacts, err := actions_model.GetArtifactsByRunAttemptAndName(ctx, run.ID, resolvedAttemptID, artifactName) + if err != nil { + ctx.ServerError("GetArtifactsByRunAttemptAndName", err) return } if len(artifacts) == 0 { @@ -929,8 +1050,10 @@ func ApproveAllChecks(ctx *context_module.Context) { return } - approveRuns(ctx, runIDs) - if ctx.Written() { + if err := actions_service.ApproveRuns(ctx, repo, ctx.Doer, runIDs); err != nil { + ctx.NotFoundOrServerError("ApproveRuns", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) return } diff --git a/routers/web/repo/activity.go b/routers/web/repo/activity.go index 4cfe879032a..420fa6d557f 100644 --- a/routers/web/repo/activity.go +++ b/routers/web/repo/activity.go @@ -48,7 +48,7 @@ func Activity(ctx *context.Context) { ctx.Data["Period"] = period ctx.Data["PeriodText"] = ctx.Tr("repo.activity.period." + period) - canReadCode := ctx.Repo.CanRead(unit.TypeCode) + canReadCode := ctx.Repo.Permission.CanRead(unit.TypeCode) if canReadCode { // GetActivityStats needs to read the default branch to get some information branchExist, _ := git_model.IsBranchExist(ctx, ctx.Repo.Repository.ID, ctx.Repo.Repository.DefaultBranch) @@ -62,9 +62,9 @@ func Activity(ctx *context.Context) { var err error // TODO: refactor these arguments to a struct ctx.Data["Activity"], err = activities_model.GetActivityStats(ctx, ctx.Repo.Repository, timeFrom, - ctx.Repo.CanRead(unit.TypeReleases), - ctx.Repo.CanRead(unit.TypeIssues), - ctx.Repo.CanRead(unit.TypePullRequests), + ctx.Repo.Permission.CanRead(unit.TypeReleases), + ctx.Repo.Permission.CanRead(unit.TypeIssues), + ctx.Repo.Permission.CanRead(unit.TypePullRequests), canReadCode, ) if err != nil { diff --git a/routers/web/repo/branch.go b/routers/web/repo/branch.go index 5e5cfec5c2b..ce0e0b03abf 100644 --- a/routers/web/repo/branch.go +++ b/routers/web/repo/branch.go @@ -39,10 +39,10 @@ const ( func Branches(ctx *context.Context) { ctx.Data["Title"] = "Branches" ctx.Data["AllowsPulls"] = ctx.Repo.Repository.AllowsPulls(ctx) - ctx.Data["IsWriter"] = ctx.Repo.CanWrite(unit.TypeCode) + ctx.Data["IsWriter"] = ctx.Repo.Permission.CanWrite(unit.TypeCode) ctx.Data["IsMirror"] = ctx.Repo.Repository.IsMirror // TODO: Can be replaced by ctx.Repo.PullRequestCtx.CanCreateNewPull() - ctx.Data["CanPull"] = ctx.Repo.CanWrite(unit.TypeCode) || + ctx.Data["CanPull"] = ctx.Repo.Permission.CanWrite(unit.TypeCode) || (ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)) ctx.Data["PageIsViewCode"] = true ctx.Data["PageIsBranches"] = true @@ -68,7 +68,7 @@ func Branches(ctx *context.Context) { ctx.ServerError("LoadBranches", err) return } - if !ctx.Repo.CanRead(unit.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { for key := range commitStatuses { git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) } @@ -231,7 +231,7 @@ func CreateBranch(ctx *context.Context) { flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.editor.push_rejected"), "Summary": ctx.Tr("repo.editor.push_rejected_summary"), - "Details": utils.SanitizeFlashErrorString(e.Message), + "Details": utils.EscapeFlashErrorString(e.Message), }) if err != nil { ctx.ServerError("UpdatePullRequest.HTMLString", err) diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 168d9594940..736a2dff003 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -384,7 +384,7 @@ func Diff(ctx *context.Context) { if err != nil { log.Error("GetLatestCommitStatus: %v", err) } - if !ctx.Repo.CanRead(unit_model.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) { git_model.CommitStatusesHideActionsURL(ctx, statuses) } @@ -410,7 +410,8 @@ func Diff(ctx *context.Context) { ctx.Data["NoteCommit"] = note.Commit ctx.Data["NoteAuthor"] = user_model.ValidateCommitWithEmail(ctx, note.Commit) rctx := renderhelper.NewRenderContextRepoComment(ctx, ctx.Repo.Repository, renderhelper.RepoCommentOptions{CurrentRefPath: path.Join("commit", util.PathEscapeSegments(commitID))}) - ctx.Data["NoteRendered"], err = markup.PostProcessCommitMessage(rctx, template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{})))) + htmlMessage := template.HTML(template.HTMLEscapeString(string(charset.ToUTF8WithFallback(note.Message, charset.ConvertOpts{})))) + ctx.Data["NoteRendered"], err = markup.PostProcessCommitMessage(rctx, htmlMessage) if err != nil { ctx.ServerError("PostProcessCommitMessage", err) return @@ -465,7 +466,7 @@ func processGitCommits(ctx *context.Context, gitCommits []*git.Commit) ([]*git_m if err != nil { return nil, err } - if !ctx.Repo.CanRead(unit_model.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) { for _, commit := range commits { if commit.Status == nil { continue diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go index 285f3968d41..7598ce561c3 100644 --- a/routers/web/repo/compare.go +++ b/routers/web/repo/compare.go @@ -421,8 +421,7 @@ func ParseCompareInfo(ctx *context.Context) *git_service.CompareInfo { } else { ctx.Data["BeforeCommitID"] = compareInfo.MergeBase } - - return compareInfo + return &compareInfo } func prepareNewPullRequestTitleContent(ci *git_service.CompareInfo, commits []*git_model.SignCommitWithStatuses) (title, content string) { @@ -708,11 +707,11 @@ func CompareDiff(ctx *context.Context) { } } - ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanWrite(unit.TypeProjects) + ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") - ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypePullRequests) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypePullRequests) if unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypePullRequests); err == nil { config := unit.PullRequestsConfig() @@ -803,7 +802,7 @@ func ExcerptBlob(ctx *context.Context) { diffBlobExcerptData.PullIssueIndex = ctx.FormInt64("pull_issue_index") if diffBlobExcerptData.PullIssueIndex > 0 { - if !ctx.Repo.CanRead(unit.TypePullRequests) { + if !ctx.Repo.Permission.CanRead(unit.TypePullRequests) { ctx.NotFound(nil) return } diff --git a/routers/web/repo/editor_error.go b/routers/web/repo/editor_error.go index e1473a34b39..f23b2738e5e 100644 --- a/routers/web/repo/editor_error.go +++ b/routers/web/repo/editor_error.go @@ -27,13 +27,13 @@ func editorHandleFileOperationErrorRender(ctx *context_service.Context, message, flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": message, "Summary": summary, - "Details": utils.SanitizeFlashErrorString(details), + "Details": utils.EscapeFlashErrorString(details), }) if err == nil { ctx.JSONError(flashError) } else { - log.Error("RenderToHTML: %v", err) - ctx.JSONError(message + "\n" + summary + "\n" + utils.SanitizeFlashErrorString(details)) + log.Error("RenderToHTML(%q, %q, %q), error: %v", message, summary, details, err) + ctx.JSONError("Unable to render error details, see server logs") // it should never happen } } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 0fe703e150c..1e0abd6ed2b 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -81,7 +81,7 @@ func MustAllowUserComment(ctx *context.Context) { return } - if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { + if issue.IsLocked && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) ctx.Redirect(issue.Link()) return @@ -90,8 +90,8 @@ func MustAllowUserComment(ctx *context.Context) { // MustEnableIssues check if repository enable internal issues func MustEnableIssues(ctx *context.Context) { - if !ctx.Repo.CanRead(unit.TypeIssues) && - !ctx.Repo.CanRead(unit.TypeExternalTracker) { + if !ctx.Repo.Permission.CanRead(unit.TypeIssues) && + !ctx.Repo.Permission.CanRead(unit.TypeExternalTracker) { ctx.NotFound(nil) return } @@ -105,7 +105,7 @@ func MustEnableIssues(ctx *context.Context) { // MustAllowPulls check if repository enable pull requests and user have right to do that func MustAllowPulls(ctx *context.Context) { - if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.Permission.CanRead(unit.TypePullRequests) { ctx.NotFound(nil) return } @@ -195,8 +195,8 @@ func GetActionIssue(ctx *context.Context) *issues_model.Issue { } func checkIssueRights(ctx *context.Context, issue *issues_model.Issue) { - if issue.IsPull && !ctx.Repo.CanRead(unit.TypePullRequests) || - !issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { + if issue.IsPull && !ctx.Repo.Permission.CanRead(unit.TypePullRequests) || + !issue.IsPull && !ctx.Repo.Permission.CanRead(unit.TypeIssues) { ctx.NotFound(nil) } } @@ -221,8 +221,8 @@ func getActionIssues(ctx *context.Context) issues_model.IssueList { return nil } // Check access rights for all issues - issueUnitEnabled := ctx.Repo.CanRead(unit.TypeIssues) - prUnitEnabled := ctx.Repo.CanRead(unit.TypePullRequests) + issueUnitEnabled := ctx.Repo.Permission.CanRead(unit.TypeIssues) + prUnitEnabled := ctx.Repo.Permission.CanRead(unit.TypePullRequests) for _, issue := range issues { if issue.RepoID != ctx.Repo.Repository.ID { ctx.NotFound(errors.New("some issue's RepoID is incorrect")) @@ -254,13 +254,13 @@ func GetIssueInfo(ctx *context.Context) { if issue.IsPull { // Need to check if Pulls are enabled and we can read Pulls - if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.Permission.CanRead(unit.TypePullRequests) { ctx.HTTPError(http.StatusNotFound) return } } else { // Need to check if Issues are enabled and we can read Issues - if !ctx.Repo.CanRead(unit.TypeIssues) { + if !ctx.Repo.Permission.CanRead(unit.TypeIssues) { ctx.HTTPError(http.StatusNotFound) return } @@ -279,7 +279,7 @@ func UpdateIssueTitle(ctx *context.Context) { return } - if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)) { ctx.HTTPError(http.StatusForbidden) return } @@ -307,7 +307,7 @@ func UpdateIssueRef(ctx *context.Context) { return } - if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) || issue.IsPull { + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)) || issue.IsPull { ctx.HTTPError(http.StatusForbidden) return } @@ -331,7 +331,7 @@ func UpdateIssueContent(ctx *context.Context) { return } - if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)) { ctx.HTTPError(http.StatusForbidden) return } @@ -387,7 +387,7 @@ func UpdateIssueDeadline(ctx *context.Context) { return } - if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + if !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) { ctx.HTTPError(http.StatusForbidden, "", "Not repo writer") return } @@ -486,7 +486,7 @@ func ChangeIssueReaction(ctx *context.Context) { return } - if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull)) { if log.IsTrace() { if ctx.IsSigned { issueType := "issues" diff --git a/routers/web/repo/issue_comment.go b/routers/web/repo/issue_comment.go index 860dcd74423..ccf9a3749cd 100644 --- a/routers/web/repo/issue_comment.go +++ b/routers/web/repo/issue_comment.go @@ -45,14 +45,14 @@ func NewComment(ctx *context.Context) { form := web.GetForm(ctx).(*forms.CreateCommentForm) issueType := util.Iif(issue.IsPull, "pulls", "issues") - if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull)) { log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ "User in Repo has Permissions: %-+v", ctx.Doer, issue.PosterID, issueType, ctx.Repo.Repository, ctx.Repo.Permission) ctx.HTTPError(http.StatusForbidden) return } - if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { + if issue.IsLocked && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { ctx.JSONError(ctx.Tr("repo.issues.comment_on_locked")) return } @@ -85,7 +85,7 @@ func NewComment(ctx *context.Context) { // TODO: need further refactoring to the code below // Check if doer can change the status of issue (close, reopen). - if (ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) || (ctx.IsSigned && issue.IsPoster(ctx.Doer.ID))) && + if (ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) || (ctx.IsSigned && issue.IsPoster(ctx.Doer.ID))) && (form.Status == "reopen" || form.Status == "close") && !(issue.IsPull && issue.PullRequest.HasMerged) { // Duplication and conflict check should apply to reopen pull request. @@ -205,7 +205,7 @@ func UpdateCommentContent(ctx *context.Context) { return } - if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.Permission.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { ctx.HTTPError(http.StatusForbidden) return } @@ -289,7 +289,7 @@ func DeleteComment(ctx *context.Context) { return } - if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.Permission.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { ctx.HTTPError(http.StatusForbidden) return } else if !comment.Type.HasContentSupport() { @@ -324,7 +324,7 @@ func ChangeCommentReaction(ctx *context.Context) { return } - if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull)) { + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.Permission.CanReadIssuesOrPulls(comment.Issue.IsPull)) { if log.IsTrace() { if ctx.IsSigned { issueType := "issues" diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go index 23cedfcb80a..01fb139c1af 100644 --- a/routers/web/repo/issue_content_history.go +++ b/routers/web/repo/issue_content_history.go @@ -88,7 +88,7 @@ func canSoftDeleteContentHistory(ctx *context.Context, issue *issues_model.Issue history *issues_model.ContentHistory, ) (canSoftDelete bool) { // CanWrite means the doer can manage the issue/PR list - if ctx.Repo.IsOwner() || ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + if ctx.Repo.Permission.IsOwner() || ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) { canSoftDelete = true } else if ctx.Doer != nil { // for read-only users, they could still post issues or comments, diff --git a/routers/web/repo/issue_list.go b/routers/web/repo/issue_list.go index 83ef515bde5..60d7a4f24dd 100644 --- a/routers/web/repo/issue_list.go +++ b/routers/web/repo/issue_list.go @@ -641,7 +641,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 ctx.ServerError("GetIssuesAllCommitStatus", err) return } - if !ctx.Repo.CanRead(unit.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { for key := range commitStatuses { git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) } @@ -700,7 +700,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6 showArchivedLabels := ctx.FormBool("archived_labels") ctx.Data["ShowArchivedLabels"] = showArchivedLabels ctx.Data["PinnedIssues"] = pinned - ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.Permission.IsAdmin() || ctx.Doer.IsAdmin) ctx.Data["IssueStats"] = issueStats ctx.Data["OpenCount"] = issueStats.OpenCount ctx.Data["ClosedCount"] = issueStats.ClosedCount @@ -759,7 +759,7 @@ func Issues(ctx *context.Context) { return } - ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.CanWriteIssuesOrPulls(isPullList) + ctx.Data["CanWriteIssuesOrPulls"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(isPullList) ctx.HTML(http.StatusOK, tplIssues) } diff --git a/routers/web/repo/issue_new.go b/routers/web/repo/issue_new.go index 98fb842ddf7..861709d2ffb 100644 --- a/routers/web/repo/issue_new.go +++ b/routers/web/repo/issue_new.go @@ -110,7 +110,7 @@ func NewIssue(ctx *context.Context) { body := ctx.FormString("body") ctx.Data["BodyQuery"] = body - isProjectsEnabled := ctx.Repo.CanRead(unit.TypeProjects) + isProjectsEnabled := ctx.Repo.Permission.CanRead(unit.TypeProjects) ctx.Data["IsProjectsEnabled"] = isProjectsEnabled ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") @@ -144,7 +144,7 @@ func NewIssue(ctx *context.Context) { ctx.Flash.Warning(renderErrorOfTemplates(ctx, ret.TemplateErrors), true) } - ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypeIssues) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeIssues) if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded { // The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters. @@ -170,7 +170,7 @@ func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) templat flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.issues.choose.ignore_invalid_templates"), "Summary": ctx.Tr("repo.issues.choose.invalid_templates", len(errs)), - "Details": utils.SanitizeFlashErrorString(strings.Join(lines, "\n")), + "Details": utils.EscapeFlashErrorString(strings.Join(lines, "\n")), }) if err != nil { log.Debug("render flash error: %v", err) @@ -344,7 +344,7 @@ func NewIssuePost(ctx *context.Context) { labelIDs, assigneeIDs, milestoneID, projectID := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectID if projectID > 0 { - if !ctx.Repo.CanRead(unit.TypeProjects) { + if !ctx.Repo.Permission.CanRead(unit.TypeProjects) { // User must also be able to see the project. ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects") return diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 639333ab425..9c7ac65a1f9 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -33,12 +33,15 @@ type issueSidebarAssigneesData struct { CandidateAssignees []*user_model.User } +type issueSidebarProjectCardData struct { + Project *project_model.Project + Columns []*project_model.Column + SelectedColumn *project_model.Column +} + type issueSidebarProjectsData struct { SelectedProjectIDs []int64 // TODO: support multiple projects in the future - - // the "selected" fields are only valid when len(SelectedProjectIDs)==1 - SelectedProjectColumns []*project_model.Column - SelectedProjectColumn *project_model.Column + ProjectCards []*issueSidebarProjectCardData OpenProjects []*project_model.Project ClosedProjects []*project_model.Project @@ -107,7 +110,7 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository // A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore. // For non-creator users, only writers could update some meta (eg: assignees, milestone, project) // Need to clarify the logic and add some tests in the future - data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived + data.CanModifyIssueOrPull = ctx.Repo.Permission.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived if !data.CanModifyIssueOrPull { return data } @@ -172,30 +175,37 @@ func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) { if d.Issue == nil || d.Issue.Project == nil { return } - d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} columns, err := d.Issue.Project.GetColumns(ctx) if err != nil { ctx.ServerError("GetProjectColumns", err) return } - d.ProjectsData.SelectedProjectColumns = columns columnID, err := d.Issue.ProjectColumnID(ctx) if err != nil { ctx.ServerError("ProjectColumnID", err) return } + var selectedColumn *project_model.Column for _, col := range columns { if col.ID == columnID { - d.ProjectsData.SelectedProjectColumn = col + selectedColumn = col break } } + d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{ + { + Project: d.Issue.Project, + Columns: columns, + SelectedColumn: selectedColumn, + }, + } + d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards)) + for _, card := range d.ProjectsData.ProjectCards { + d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID) + } } func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { - if d.Issue != nil && d.Issue.Project != nil { - d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID} - } d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository) } diff --git a/routers/web/repo/issue_suggestions.go b/routers/web/repo/issue_suggestions.go index 9ef39425041..592cc7b1d53 100644 --- a/routers/web/repo/issue_suggestions.go +++ b/routers/web/repo/issue_suggestions.go @@ -16,8 +16,8 @@ import ( func IssueSuggestions(ctx *context.Context) { keyword := ctx.Req.FormValue("q") - canReadIssues := ctx.Repo.CanRead(unit.TypeIssues) - canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests) + canReadIssues := ctx.Repo.Permission.CanRead(unit.TypeIssues) + canReadPulls := ctx.Repo.Permission.CanRead(unit.TypePullRequests) var isPull optional.Option[bool] if canReadPulls && !canReadIssues { diff --git a/routers/web/repo/issue_timetrack.go b/routers/web/repo/issue_timetrack.go index b9ed059fde9..e93a3107a3d 100644 --- a/routers/web/repo/issue_timetrack.go +++ b/routers/web/repo/issue_timetrack.go @@ -91,7 +91,7 @@ func UpdateIssueTimeEstimate(ctx *context.Context) { return } - if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)) { ctx.HTTPError(http.StatusForbidden) return } diff --git a/routers/web/repo/issue_view.go b/routers/web/repo/issue_view.go index 250a54fc24a..af13a1156ed 100644 --- a/routers/web/repo/issue_view.go +++ b/routers/web/repo/issue_view.go @@ -29,7 +29,6 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates/vars" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" @@ -118,19 +117,6 @@ func roleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *u return roleDesc, nil } -func getBranchData(ctx *context.Context, issue *issues_model.Issue) { - ctx.Data["BaseBranch"] = nil - ctx.Data["HeadBranch"] = nil - ctx.Data["HeadUserName"] = nil - ctx.Data["BaseName"] = ctx.Repo.Repository.OwnerName - if issue.IsPull { - pull := issue.PullRequest - ctx.Data["BaseBranch"] = pull.BaseBranch - ctx.Data["HeadBranch"] = pull.HeadBranch - ctx.Data["HeadUserName"] = pull.MustHeadUserName(ctx) - } -} - // checkBlockedByIssues return canRead and notPermitted func checkBlockedByIssues(ctx *context.Context, blockers []*issues_model.DependencyInfo) (canRead, notPermitted []*issues_model.DependencyInfo) { repoPerms := make(map[int64]access_model.Permission) @@ -350,7 +336,7 @@ func ViewIssue(ctx *context.Context) { ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) } - ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects) + ctx.Data["IsProjectsEnabled"] = ctx.Repo.Permission.CanRead(unit.TypeProjects) ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") @@ -380,6 +366,7 @@ func ViewIssue(ctx *context.Context) { } pageMetaData.LabelsData.SetSelectedLabels(issue.Labels) + prViewInfo := newPullRequestViewInfo() prepareFuncs := []func(*context.Context, *issues_model.Issue){ prepareIssueViewContent, prepareIssueViewCommentsAndSidebarParticipants, @@ -387,10 +374,13 @@ func ViewIssue(ctx *context.Context) { prepareIssueViewSidebarTimeTracker, prepareIssueViewSidebarDependency, prepareIssueViewSidebarPin, - func(ctx *context.Context, issue *issues_model.Issue) { preparePullViewPullInfo(ctx, issue) }, - preparePullViewReviewAndMerge, } - + if issue.IsPull { + prepareFuncs = append(prepareFuncs, + prViewInfo.prepareViewInfo, + prViewInfo.prepareMergeBox, + ) + } for _, prepareFunc := range prepareFuncs { prepareFunc(ctx, issue) if ctx.Written() { @@ -403,16 +393,16 @@ func ViewIssue(ctx *context.Context) { if issue.PullRequest.HasMerged { ctx.Data["DisableStatusChange"] = issue.PullRequest.HasMerged } else { - ctx.Data["DisableStatusChange"] = ctx.Data["IsPullRequestBroken"] == true && issue.IsClosed + ctx.Data["DisableStatusChange"] = prViewInfo.IsPullRequestBroken && issue.IsClosed } } ctx.Data["Reference"] = issue.Ref ctx.Data["SignInLink"] = middleware.RedirectLinkUserLogin(ctx.Req) ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) - ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) - ctx.Data["HasProjectsWritePermission"] = ctx.Repo.CanWrite(unit.TypeProjects) - ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["HasProjectsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.Permission.IsAdmin() || ctx.Doer.IsAdmin) ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons ctx.Data["RefEndName"] = git.RefName(issue.Ref).ShortName() @@ -427,8 +417,8 @@ func ViewIssue(ctx *context.Context) { return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) } - if issue.PullRequest != nil && !issue.PullRequest.IsChecking() && !setting.IsProd { - ctx.Data["PullMergeBoxReloadingInterval"] = 1 // in dev env, force using the reloading logic to make sure it won't break + if !setting.IsProd && issue.PullRequest != nil && !issue.PullRequest.IsChecking() && prViewInfo.MergeBoxData != nil { + prViewInfo.MergeBoxData.ReloadingInterval = 1 // in dev env, force using the reloading logic to make sure it won't break } ctx.HTML(http.StatusOK, tplIssueView) @@ -443,20 +433,27 @@ func ViewPullMergeBox(ctx *context.Context) { ctx.NotFound(nil) return } - preparePullViewPullInfo(ctx, issue) - preparePullViewReviewAndMerge(ctx, issue) + prViewInfo := newPullRequestViewInfo() + prViewInfo.prepareViewInfo(ctx, issue) + if ctx.Written() { + return + } + prViewInfo.prepareMergeBox(ctx, issue) + if ctx.Written() { + return + } ctx.Data["PullMergeBoxReloading"] = issue.PullRequest.IsChecking() // TODO: it should use a dedicated struct to render the pull merge box, to make sure all data is prepared correctly ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) - ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) ctx.HTML(http.StatusOK, tplPullMergeBox) } func prepareIssueViewSidebarDependency(ctx *context.Context, issue *issues_model.Issue) { - if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { + if issue.IsPull && !ctx.Repo.Permission.CanRead(unit.TypeIssues) { ctx.Data["IssueDependencySearchType"] = "pulls" - } else if !issue.IsPull && !ctx.Repo.CanRead(unit.TypePullRequests) { + } else if !issue.IsPull && !ctx.Repo.Permission.CanRead(unit.TypePullRequests) { ctx.Data["IssueDependencySearchType"] = "issues" } else { ctx.Data["IssueDependencySearchType"] = "all" @@ -488,15 +485,12 @@ func prepareIssueViewSidebarDependency(ctx *context.Context, issue *issues_model ctx.Data["BlockingDependencies"], ctx.Data["BlockingDependenciesNotPermitted"] = checkBlockedByIssues(ctx, blocking) } -func preparePullViewSigning(ctx *context.Context, issue *issues_model.Issue) { - if !issue.IsPull { - return - } - pull := issue.PullRequest - ctx.Data["WillSign"] = false +func (prInfo *pullRequestViewInfo) prepareMergeBoxRequireSigning(ctx *context.Context) { + pull := prInfo.issue.PullRequest + willSign := false if ctx.Doer != nil { sign, key, _, err := asymkey_service.SignMerge(ctx, pull, ctx.Doer, ctx.Repo.GitRepo) - ctx.Data["WillSign"] = sign + willSign = sign ctx.Data["SigningKeyMergeDisplay"] = asymkey_model.GetDisplaySigningKey(key) if err != nil { if asymkey_service.IsErrWontSign(err) { @@ -509,6 +503,8 @@ func preparePullViewSigning(ctx *context.Context, issue *issues_model.Issue) { } else { ctx.Data["WontSignReason"] = "not_signed_in" } + ctx.Data["WillSign"] = willSign + prInfo.MergeBoxData.willSign = willSign } func prepareIssueViewSidebarWatch(ctx *context.Context, issue *issues_model.Issue) { @@ -558,14 +554,11 @@ func prepareIssueViewSidebarTimeTracker(ctx *context.Context, issue *issues_mode } } -func preparePullViewDeleteBranch(ctx *context.Context, issue *issues_model.Issue, canDelete bool) { - if !issue.IsPull { - return - } - pull := issue.PullRequest +func (prInfo *pullRequestViewInfo) prepareMergeBoxDeleteBranch(ctx *context.Context, canDelete bool) { + pull := prInfo.issue.PullRequest isPullBranchDeletable := canDelete && pull.HeadRepo != nil && - (!pull.HasMerged || ctx.Data["HeadBranchCommitID"] == ctx.Data["PullHeadCommitID"]) + (!pull.HasMerged || prInfo.HeadBranchCommitID == prInfo.CompareInfo.HeadCommitID) if isPullBranchDeletable { isPullBranchDeletable, _ = git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch) } @@ -580,6 +573,7 @@ func preparePullViewDeleteBranch(ctx *context.Context, issue *issues_model.Issue isPullBranchDeletable = !exist } ctx.Data["IsPullBranchDeletable"] = isPullBranchDeletable + prInfo.MergeBoxData.isPullBranchDeletable = isPullBranchDeletable } func prepareIssueViewSidebarPin(ctx *context.Context, issue *issues_model.Issue) { @@ -769,7 +763,7 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue ctx.ServerError("LoadCommentPushCommits", err) return } - if !ctx.Repo.CanRead(unit.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { for _, commit := range comment.Commits { if commit.Status == nil { continue @@ -781,14 +775,14 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue } else if comment.Type == issues_model.CommentTypeAddTimeManual || comment.Type == issues_model.CommentTypeStopTracking || comment.Type == issues_model.CommentTypeDeleteTimeManual { - // drop error since times could be pruned from DB.. + // drop error since times could be pruned from DB _ = comment.LoadTime(ctx) if comment.Content != "" { // Content before v1.21 did store the formatted string instead of seconds, // so "|" is used as delimiter to mark the new format if comment.Content[0] != '|' { // handle old time comments that have formatted text stored - comment.RenderedContent = templates.SanitizeHTML(comment.Content) + comment.RenderedContent = markup.Sanitize(comment.Content) comment.Content = "" } else { // else it's just a duration in seconds to pass on to the frontend @@ -827,20 +821,42 @@ func prepareIssueViewCommentsAndSidebarParticipants(ctx *context.Context, issue ctx.Data["NumParticipants"] = len(participants) } -func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Issue) { - getBranchData(ctx, issue) - if !issue.IsPull { - return +func (prInfo *pullRequestViewInfo) prepareMergeBox(ctx *context.Context, issue *issues_model.Issue) { + if prInfo.issue != issue { + panic("impossible, issue must be the same") + } + + data := &pullMergeBoxData{} + prInfo.MergeBoxData = data + + statusCheckData := prInfo.StatusCheckData + if statusCheckData == nil { + statusCheckData = &pullCommitStatusCheckData{} // make the following logic easier, no need to keep checking "nil" } pull := issue.PullRequest - pull.Issue = issue canDelete := false allowMerge := false canWriteToHeadRepo := false pull_service.StartPullRequestCheckOnView(ctx, pull) + ctx.Data["GetCommitMessages"] = "" + if !prInfo.IsPullRequestBroken { + var err error + ctx.Data["UpdateAllowed"], ctx.Data["UpdateByRebaseAllowed"], err = pull_service.IsUserAllowedToUpdate(ctx, pull, ctx.Doer) + if err != nil { + ctx.ServerError("IsUserAllowedToUpdate", err) + return + } + ctx.Data["GetCommitMessages"] = pull_service.GetSquashMergeCommitMessages(ctx, pull) + } + + if pull.IsFilesConflicted() { + ctx.Data["IsPullFilesConflicted"] = true + ctx.Data["ConflictedFiles"] = pull.ConflictedFiles + } + if ctx.IsSigned { if err := pull.LoadHeadRepo(ctx); err != nil { log.Error("LoadHeadRepo: %v", err) @@ -887,7 +903,7 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss } } - ctx.Data["PullMergeBoxReloadingInterval"] = util.Iif(pull != nil && pull.IsChecking(), 2000, 0) + data.ReloadingInterval = util.Iif(pull != nil && pull.IsChecking(), 2000, 0) ctx.Data["CanWriteToHeadRepo"] = canWriteToHeadRepo ctx.Data["ShowMergeInstructions"] = canWriteToHeadRepo ctx.Data["AllowMerge"] = allowMerge @@ -940,33 +956,41 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss ctx.Data["DefaultSquashMergeMessage"] = defaultSquashMergeMessage ctx.Data["DefaultSquashMergeBody"] = defaultSquashMergeBody - pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) - if err != nil { - ctx.ServerError("LoadProtectedBranch", err) - return - } - + pb := prInfo.ProtectedBranchRule if pb != nil { pb.Repo = pull.BaseRepo ctx.Data["ProtectedBranch"] = pb - ctx.Data["IsBlockedByApprovals"] = !issues_model.HasEnoughApprovals(ctx, pb, pull) - ctx.Data["IsBlockedByRejection"] = issues_model.MergeBlockedByRejectedReview(ctx, pb, pull) - ctx.Data["IsBlockedByOfficialReviewRequests"] = issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pull) - ctx.Data["IsBlockedByOutdatedBranch"] = issues_model.MergeBlockedByOutdatedBranch(pb, pull) + + data.isBlockedByApprovals = !issues_model.HasEnoughApprovals(ctx, pb, pull) + ctx.Data["IsBlockedByApprovals"] = data.isBlockedByApprovals + + data.isBlockedByRejection = issues_model.MergeBlockedByRejectedReview(ctx, pb, pull) + ctx.Data["IsBlockedByRejection"] = data.isBlockedByRejection + + data.isBlockedByOfficialReviewRequests = issues_model.MergeBlockedByOfficialReviewRequests(ctx, pb, pull) + ctx.Data["IsBlockedByOfficialReviewRequests"] = data.isBlockedByOfficialReviewRequests + + data.isBlockedByOutdatedBranch = issues_model.MergeBlockedByOutdatedBranch(pb, pull) + ctx.Data["IsBlockedByOutdatedBranch"] = data.isBlockedByOutdatedBranch + + data.isBlockedByChangedProtectedFiles = len(pull.ChangedProtectedFiles) != 0 + ctx.Data["IsBlockedByChangedProtectedFiles"] = data.isBlockedByChangedProtectedFiles + + data.requireSigned = pb.RequireSignedCommits + ctx.Data["RequireSigned"] = data.requireSigned + ctx.Data["GrantedApprovals"] = issues_model.GetGrantedApprovalsCount(ctx, pb, pull) - ctx.Data["RequireSigned"] = pb.RequireSignedCommits ctx.Data["ChangedProtectedFiles"] = pull.ChangedProtectedFiles - ctx.Data["IsBlockedByChangedProtectedFiles"] = len(pull.ChangedProtectedFiles) != 0 ctx.Data["ChangedProtectedFilesNum"] = len(pull.ChangedProtectedFiles) ctx.Data["RequireApprovalsWhitelist"] = pb.EnableApprovalsWhitelist } - preparePullViewSigning(ctx, issue) + prInfo.prepareMergeBoxRequireSigning(ctx) if ctx.Written() { return } - preparePullViewDeleteBranch(ctx, issue, canDelete) + prInfo.prepareMergeBoxDeleteBranch(ctx, canDelete) if ctx.Written() { return } @@ -975,14 +999,10 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss if pull.HasMerged || issue.IsClosed || !ctx.IsSigned { return false } - if pull.CanAutoMerge() || pull.IsWorkInProgress(ctx) || pull.IsChecking() { + if pull.IsStatusMergeable() || pull.IsWorkInProgress(ctx) || pull.IsChecking() { return false } - if allowMerge && prConfig.AllowManualMerge { - return true - } - - return false + return allowMerge && prConfig.AllowManualMerge } ctx.Data["StillCanManualMerge"] = stillCanManualMerge() @@ -993,6 +1013,36 @@ func preparePullViewReviewAndMerge(ctx *context.Context, issue *issues_model.Iss ctx.ServerError("GetScheduledMergeByPullID", err) return } + + enableStatusCheck := pb != nil && pb.EnableStatusCheck + ctx.Data["EnableStatusCheck"] = enableStatusCheck + + // Only show the merge box if the PR is not merged, or the branch is deletable. + // Otherwise, there is nothing to do, because the PR view page already contains enough information. + data.ShowMergeBox = !pull.HasMerged || data.isPullBranchDeletable + + isRepoAdmin := ctx.IsSigned && (ctx.Repo.Permission.IsAdmin() || ctx.Doer.IsAdmin) + + // admin can merge without checks, writer can merge when checks succeed + // admin and writer both can make an auto merge schedule (not affected by overridable blockers) + data.hasStatusCheckBlocker = enableStatusCheck && !statusCheckData.RequiredChecksState.IsSuccess() + + // this logic is from: + // {{$notAllOverridableChecksOk := or .IsBlockedByApprovals .IsBlockedByRejection .IsBlockedByOfficialReviewRequests .IsBlockedByOutdatedBranch .IsBlockedByChangedProtectedFiles (and .EnableStatusCheck (not $requiredStatusCheckState.IsSuccess))}} + // HINT: if a PR's status is not mergeable, then it is a non-overridable blocker, such logic is handled separately (see IsStatusMergeable) + data.HasOverridableBlockers = data.isBlockedByApprovals || data.isBlockedByRejection || + data.isBlockedByOfficialReviewRequests || data.isBlockedByOutdatedBranch || data.isBlockedByChangedProtectedFiles || + data.hasStatusCheckBlocker + + // this logic is from: + // {{$canMergeNow := and (or (and (not $.ProtectedBranch.BlockAdminMergeOverride) $.IsRepoAdmin) (not $notAllOverridableChecksOk)) (or (not .AllowMerge) (not .RequireSigned) .WillSign)}} + // HINT: legacy "(not .AllowMerge)" is not right (always false, does nothing), fixed here + // CanMergeNow means: if the doer has write permission, whether the PR can be merged now + adminCanOverrideBlockers := (pb == nil || !pb.BlockAdminMergeOverride) && isRepoAdmin + data.CanMergeNow = (!data.HasOverridableBlockers || adminCanOverrideBlockers) && // status checks are satisfied + (!data.requireSigned || data.willSign) // signing requirement is satisfied + + ctx.Data["PullMergeBoxData"] = prInfo.MergeBoxData } func prepareIssueViewContent(ctx *context.Context, issue *issues_model.Issue) { diff --git a/routers/web/repo/issue_watch.go b/routers/web/repo/issue_watch.go index dfa3491786e..abb2a81d9e1 100644 --- a/routers/web/repo/issue_watch.go +++ b/routers/web/repo/issue_watch.go @@ -5,7 +5,6 @@ package repo import ( "net/http" - "strconv" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/modules/log" @@ -24,7 +23,7 @@ func IssueWatch(ctx *context.Context) { return } - if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { + if !ctx.IsSigned || (ctx.Doer.ID != issue.PosterID && !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull)) { if log.IsTrace() { if ctx.IsSigned { issueType := "issues" @@ -46,12 +45,7 @@ func IssueWatch(ctx *context.Context) { return } - watch, err := strconv.ParseBool(ctx.Req.PostFormValue("watch")) - if err != nil { - ctx.ServerError("watch is not bool", err) - return - } - + watch := ctx.FormBool("watch") if err := issues_model.CreateOrUpdateIssueWatch(ctx, ctx.Doer.ID, issue.ID, watch); err != nil { ctx.ServerError("CreateOrUpdateIssueWatch", err) return diff --git a/routers/web/repo/milestone.go b/routers/web/repo/milestone.go index b928be28673..5e23c1c413c 100644 --- a/routers/web/repo/milestone.go +++ b/routers/web/repo/milestone.go @@ -265,8 +265,8 @@ func MilestoneIssuesAndPulls(ctx *context.Context) { ret := issue.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) ctx.Data["NewIssueChooseTemplate"] = len(ret.IssueTemplates) > 0 - ctx.Data["CanWriteIssues"] = ctx.Repo.CanWriteIssuesOrPulls(false) - ctx.Data["CanWritePulls"] = ctx.Repo.CanWriteIssuesOrPulls(true) + ctx.Data["CanWriteIssues"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(false) + ctx.Data["CanWritePulls"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(true) ctx.HTML(http.StatusOK, tplMilestoneIssues) } diff --git a/routers/web/repo/packages.go b/routers/web/repo/packages.go index cfb788a5b27..6dd54e42e2c 100644 --- a/routers/web/repo/packages.go +++ b/routers/web/repo/packages.go @@ -59,7 +59,7 @@ func Packages(ctx *context.Context) { ctx.Data["PackageType"] = packageType ctx.Data["AvailableTypes"] = packages.TypeList ctx.Data["HasPackages"] = hasPackages - ctx.Data["CanWritePackages"] = ctx.Repo.CanWrite(unit.TypePackages) || ctx.IsUserSiteAdmin() + ctx.Data["CanWritePackages"] = ctx.Repo.Permission.CanWrite(unit.TypePackages) || ctx.IsUserSiteAdmin() ctx.Data["PackageDescriptors"] = pds ctx.Data["Total"] = total ctx.Data["RepositoryAccessMap"] = map[int64]bool{ctx.Repo.Repository.ID: true} // There is only the current repository diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index c9bdc5be76e..a94051f2980 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -45,7 +45,7 @@ func MustEnableRepoProjects(ctx *context.Context) { if ctx.Repo.Repository != nil { projectsUnit := ctx.Repo.Repository.MustGetUnit(ctx, unit.TypeProjects) - if !ctx.Repo.CanRead(unit.TypeProjects) || !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) { + if !ctx.Repo.Permission.CanRead(unit.TypeProjects) || !projectsUnit.ProjectsConfig().IsProjectsAllowed(repo_model.ProjectsModeRepo) { ctx.NotFound(nil) return } @@ -464,6 +464,54 @@ func UpdateIssueProject(ctx *context.Context) { ctx.JSONOK() } +// UpdateIssueProjectColumn moves an issue to a different column within its project +func UpdateIssueProjectColumn(ctx *context.Context) { + issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("issue_id")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err) + return + } + column, err := project_model.GetColumn(ctx, ctx.FormInt64("id")) + if err != nil { + ctx.NotFoundOrServerError("GetColumn", project_model.IsErrProjectColumnNotExist, err) + return + } + + if err := issue.LoadProject(ctx); err != nil { + ctx.ServerError("LoadProject", err) + return + } + + issueProjects := []*project_model.Project{issue.Project} // TODO: this is for the multiple project support in the future + + // it must make sure the requested column is in this issue's projects + var columnProject *project_model.Project + for _, project := range issueProjects { + if column.ProjectID == project.ID { + columnProject = project + break + } + } + if columnProject == nil { + ctx.NotFound(nil) + return + } + + // append to the end of the target column so we don't collide with existing sorting values + newSorting, err := project_model.GetColumnIssueNextSorting(ctx, columnProject.ID, column.ID) + if err != nil { + ctx.ServerError("GetColumnIssueNextSorting", err) + return + } + + if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{newSorting: issue.ID}); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + + ctx.JSONOK() +} + // DeleteProjectColumn allows for the deletion of a project column func DeleteProjectColumn(ctx *context.Context) { if ctx.Doer == nil { @@ -473,7 +521,7 @@ func DeleteProjectColumn(ctx *context.Context) { return } - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + if !ctx.Repo.Permission.IsOwner() && !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only authorized users are allowed to perform this action.", }) @@ -520,7 +568,7 @@ func DeleteProjectColumn(ctx *context.Context) { // AddColumnToProjectPost allows a new column to be added to a project. func AddColumnToProjectPost(ctx *context.Context) { form := web.GetForm(ctx).(*forms.EditProjectColumnForm) - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + if !ctx.Repo.Permission.IsOwner() && !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only authorized users are allowed to perform this action.", }) @@ -558,7 +606,7 @@ func checkProjectColumnChangePermissions(ctx *context.Context) (*project_model.P return nil, nil } - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + if !ctx.Repo.Permission.IsOwner() && !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only authorized users are allowed to perform this action.", }) @@ -644,7 +692,7 @@ func MoveIssues(ctx *context.Context) { return } - if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + if !ctx.Repo.Permission.IsOwner() && !ctx.Repo.Permission.IsAdmin() && !ctx.Repo.Permission.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { ctx.JSON(http.StatusForbidden, map[string]string{ "message": "Only authorized users are allowed to perform this action.", }) diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index e312fc9d2a8..c532cbba22d 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -161,11 +161,12 @@ func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { return issue, true } -func setMergeTarget(ctx *context.Context, pull *issues_model.PullRequest) { +func (prInfo *pullRequestViewInfo) setTemplateDataMergeTarget(ctx *context.Context) { + pull := prInfo.issue.PullRequest if ctx.Repo.Owner.Name == pull.MustHeadUserName(ctx) { ctx.Data["HeadTarget"] = pull.HeadBranch } else if pull.HeadRepo == nil { - ctx.Data["HeadTarget"] = pull.MustHeadUserName(ctx) + ":" + pull.HeadBranch + ctx.Data["HeadTarget"] = ctx.Locale.Tr("repo.pull.deleted_branch", pull.HeadBranch) } else { ctx.Data["HeadTarget"] = pull.MustHeadUserName(ctx) + "/" + pull.HeadRepo.Name + ":" + pull.HeadBranch } @@ -260,60 +261,214 @@ func GetMergedBaseCommitID(ctx *context.Context, issue *issues_model.Issue) stri return baseCommit } -func preparePullViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git_service.CompareInfo { - if !issue.IsPull { - return nil - } - if issue.PullRequest.HasMerged { - return prepareMergedViewPullInfo(ctx, issue) - } - return prepareViewPullInfo(ctx, issue) +type pullMergeBoxData struct { + ShowMergeBox bool + ReloadingInterval int + + HasOverridableBlockers bool + CanMergeNow bool + + // don't expose unneeded fields to templates, need more refactoring changes + hasStatusCheckBlocker bool + isPullBranchDeletable bool + + isBlockedByApprovals bool + isBlockedByRejection bool + isBlockedByOfficialReviewRequests bool + isBlockedByOutdatedBranch bool + isBlockedByChangedProtectedFiles bool + requireSigned, willSign bool } -// prepareMergedViewPullInfo show meta information for a merged pull request view page -func prepareMergedViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git_service.CompareInfo { - pull := issue.PullRequest +// pullRequestViewInfo is a structured type for viewing pull request +// Refactoring plan: +// * move dynamic template-data-based variable into this struct +// * let backend handle complex logic, prepare everything, avoid plenty of "if" blocks in tmpl +type pullRequestViewInfo struct { + issue *issues_model.Issue - setMergeTarget(ctx, pull) - ctx.Data["HasMerged"] = true + IsPullRequestBroken bool + HeadBranchCommitID string - baseCommit := GetMergedBaseCommitID(ctx, issue) + CompareInfo git_service.CompareInfo + ProtectedBranchRule *git_model.ProtectedBranch + StatusCheckData *pullCommitStatusCheckData + CommitStatuses []*git_model.CommitStatus + MergeBoxData *pullMergeBoxData +} - compareInfo, err := git_service.GetCompareInfo(ctx, ctx.Repo.Repository, ctx.Repo.Repository, ctx.Repo.GitRepo, - git.RefName(baseCommit), git.RefName(pull.GetGitHeadRefName()), false, false) +func newPullRequestViewInfo() *pullRequestViewInfo { + return &pullRequestViewInfo{} +} + +func (prInfo *pullRequestViewInfo) prepareViewInfo(ctx *context.Context, issue *issues_model.Issue) { + prInfo.issue = issue + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + + if err := issue.PullRequest.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("LoadHeadRepo", err) + return + } + + if err := issue.PullRequest.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("LoadBaseRepo", err) + return + } + + // for the PR target branch selector + ctx.Data["BaseBranch"] = issue.PullRequest.BaseBranch + ctx.Data["HeadBranch"] = issue.PullRequest.HeadBranch + ctx.Data["HeadUserName"] = issue.PullRequest.MustHeadUserName(ctx) + + if issue.PullRequest.HasMerged { + prInfo.prepareViewMergedPullInfo(ctx) + } else { + prInfo.prepareViewOpenPullInfo(ctx) + } +} + +func (prInfo *pullRequestViewInfo) prepareViewFillInfo(ctx *context.Context, baseRef git.RefName) { + prInfo.prepareViewFillCompareInfo(ctx, baseRef) + if ctx.Written() { + return + } + prInfo.prepareViewFillCommitStatusInfo(ctx) +} + +func (prInfo *pullRequestViewInfo) prepareViewFillCompareInfo(ctx *context.Context, baseRef git.RefName) { + var err error + pull := prInfo.issue.PullRequest + prInfo.CompareInfo, err = git_service.GetCompareInfo(ctx, ctx.Repo.Repository, ctx.Repo.Repository, ctx.Repo.GitRepo, baseRef, git.RefName(pull.GetGitHeadRefName()), false, false) if err != nil { - if gitcmd.IsStdErrorNotValidObjectName(err) || strings.Contains(err.Error(), "unknown revision or path not in the working tree") { - ctx.Data["IsPullRequestBroken"] = true - ctx.Data["BaseTarget"] = pull.BaseBranch - ctx.Data["NumCommits"] = 0 - ctx.Data["NumFiles"] = 0 - return nil - } - - ctx.ServerError("GetCompareInfo", err) - return nil - } - ctx.Data["NumCommits"] = len(compareInfo.Commits) - ctx.Data["NumFiles"] = compareInfo.NumFiles - - if len(compareInfo.Commits) != 0 { - sha := compareInfo.Commits[0].ID.String() - commitStatuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, sha, db.ListOptionsAll) - if err != nil { - ctx.ServerError("GetLatestCommitStatus", err) - return nil - } - if !ctx.Repo.CanRead(unit.TypeActions) { - git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) - } - - if len(commitStatuses) != 0 { - ctx.Data["LatestCommitStatuses"] = commitStatuses - ctx.Data["LatestCommitStatus"] = git_model.CalcCommitStatus(commitStatuses) + isKnownErrorForBroken := gitcmd.IsStdErrorNotValidObjectName(err) || + // fatal: ambiguous argument 'origin': unknown revision or path not in the working tree. + gitcmd.StderrContains(err, "unknown revision or path not in the working tree") + if !isKnownErrorForBroken { + log.Error("GetCompareInfo: %v", err) } + prInfo.IsPullRequestBroken = true } - return compareInfo + prInfo.HeadBranchCommitID, err = getViewPullHeadBranchCommitID(ctx, pull) + if err != nil { + if !errors.Is(err, util.ErrNotExist) { + log.Error("GetViewPullHeadBranchCommitID: %v", err) + } + prInfo.IsPullRequestBroken = true + } + if !pull.Issue.IsClosed && (prInfo.HeadBranchCommitID != prInfo.CompareInfo.HeadCommitID) { + // if the PR is still open, but its "branch commit in head repo" + // doesn't match "the PR's internal git ref commit in base repo", then the PR is broken + prInfo.IsPullRequestBroken = true + } + + ctx.Data["IsPullRequestBroken"] = prInfo.IsPullRequestBroken + ctx.Data["NumCommits"] = len(prInfo.CompareInfo.Commits) + ctx.Data["NumFiles"] = prInfo.CompareInfo.NumFiles + prInfo.setTemplateDataMergeTarget(ctx) +} + +func (prInfo *pullRequestViewInfo) prepareViewFillCommitStatusInfo(ctx *context.Context) { + headCommitID := prInfo.CompareInfo.HeadCommitID + if headCommitID == "" { + return + } + + repo := ctx.Repo.Repository + statusCheckData := &pullCommitStatusCheckData{} + prInfo.StatusCheckData = statusCheckData + + commitStatuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, prInfo.CompareInfo.HeadCommitID, db.ListOptionsAll) + if err != nil { + ctx.ServerError("GetLatestCommitStatus", err) + return + } + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { + git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) + } + + prInfo.CommitStatuses = commitStatuses + statusCheckData.ApproveLink = fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", repo.Link(), headCommitID) + statusCheckData.LatestCommitStatus = git_model.CalcCommitStatus(commitStatuses) + ctx.Data["LatestCommitStatuses"] = commitStatuses + ctx.Data["LatestCommitStatus"] = statusCheckData.LatestCommitStatus + ctx.Data["StatusCheckData"] = prInfo.StatusCheckData + + prInfo.ProtectedBranchRule, err = git_model.GetFirstMatchProtectedBranchRule(ctx, ctx.Repo.Repository.ID, prInfo.issue.PullRequest.BaseBranch) + if err != nil { + ctx.ServerError("GetFirstMatchProtectedBranchRule", err) + return + } + + if !prInfo.issue.IsClosed { + prInfo.prepareViewFillCommitStatusInfoForOpen(ctx) + } +} + +func (prInfo *pullRequestViewInfo) prepareViewFillCommitStatusInfoForOpen(ctx *context.Context) { + statusCheckData := prInfo.StatusCheckData + commitStatuses := prInfo.CommitStatuses + runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses) + if err != nil { + ctx.ServerError("GetRunsFromCommitStatuses", err) + return + } + for _, run := range runs { + if run.NeedApproval { + statusCheckData.RequireApprovalRunCount++ + } + } + if statusCheckData.RequireApprovalRunCount > 0 { + statusCheckData.CanApprove = ctx.Repo.Permission.CanWrite(unit.TypeActions) + } + + pb := prInfo.ProtectedBranchRule + enableStatusCheck := pb != nil && pb.EnableStatusCheck + if !enableStatusCheck { + return + } + + var missingRequiredChecks []string + for _, requiredContext := range pb.StatusCheckContexts { + contextFound := false + matchesRequiredContext := createRequiredContextMatcher(requiredContext) + for _, presentStatus := range commitStatuses { + if matchesRequiredContext(presentStatus.Context) { + contextFound = true + break + } + } + + if !contextFound { + missingRequiredChecks = append(missingRequiredChecks, requiredContext) + } + } + statusCheckData.MissingRequiredChecks = missingRequiredChecks + + statusCheckData.IsContextRequired = func(context string) bool { + for _, c := range pb.StatusCheckContexts { + if c == context { + return true + } + if gp, err := glob.Compile(c); err != nil { + // All newly created status_check_contexts are checked to ensure they are valid glob expressions before being stored in the database. + // But some old status_check_context created before glob was introduced may be invalid glob expressions. + // So log the error here for debugging. + log.Error("compile glob %q: %v", c, err) + } else if gp.Match(context) { + return true + } + } + return false + } + statusCheckData.RequiredChecksState = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, pb.StatusCheckContexts) +} + +// prepareViewMergedPullInfo show meta information for a merged pull request view page +func (prInfo *pullRequestViewInfo) prepareViewMergedPullInfo(ctx *context.Context) { + ctx.Data["HasMerged"] = true + baseCommit := GetMergedBaseCommitID(ctx, prInfo.issue) + prInfo.prepareViewFillInfo(ctx, git.RefName(baseCommit)) } type pullCommitStatusCheckData struct { @@ -344,275 +499,53 @@ func (d *pullCommitStatusCheckData) CommitStatusCheckPrompt(locale translation.L return locale.TrString("repo.pulls.status_checking") } -func getViewPullHeadBranchInfo(ctx *context.Context, pull *issues_model.PullRequest, baseGitRepo *git.Repository) (headCommitID string, headCommitExists bool, err error) { - if pull.HeadRepo == nil { - return "", false, nil - } - headGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pull.HeadRepo) - if err != nil { - return "", false, util.Iif(errors.Is(err, util.ErrNotExist), nil, err) - } - defer closer.Close() - - if pull.Flow == issues_model.PullRequestFlowGithub { - headCommitExists, _ = git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch) - } else { - headCommitExists = gitrepo.IsReferenceExist(ctx, pull.BaseRepo, pull.GetGitHeadRefName()) - } - - if headCommitExists { - if pull.Flow != issues_model.PullRequestFlowGithub { - headCommitID, err = baseGitRepo.GetRefCommitID(pull.GetGitHeadRefName()) - } else { - headCommitID, err = headGitRepo.GetBranchCommitID(pull.HeadBranch) +func getViewPullHeadBranchCommitID(ctx *context.Context, pull *issues_model.PullRequest) (string, error) { + switch pull.Flow { + case issues_model.PullRequestFlowGithub: + if pull.HeadRepo == nil { + return "", util.ErrNotExist } + headGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, pull.HeadRepo) if err != nil { - return "", false, util.Iif(errors.Is(err, util.ErrNotExist), nil, err) + return "", err } + return headGitRepo.GetRefCommitID(git.RefNameFromBranch(pull.HeadBranch).String()) + case issues_model.PullRequestFlowAGit: + baseGitRepo, err := gitrepo.RepositoryFromRequestContextOrOpen(ctx, pull.BaseRepo) + if err != nil { + return "", err + } + return baseGitRepo.GetRefCommitID(pull.GetGitHeadRefName()) } - return headCommitID, headCommitExists, nil + setting.PanicInDevOrTesting("invalid pull request flow type: %v", pull.Flow) + return "", util.ErrNotExist } -// prepareViewPullInfo show meta information for a pull request preview page -func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git_service.CompareInfo { - ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes - - repo := ctx.Repo.Repository - pull := issue.PullRequest - - if err := pull.LoadHeadRepo(ctx); err != nil { - ctx.ServerError("LoadHeadRepo", err) - return nil - } - - if err := pull.LoadBaseRepo(ctx); err != nil { - ctx.ServerError("LoadBaseRepo", err) - return nil - } - - setMergeTarget(ctx, pull) - - pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, repo.ID, pull.BaseBranch) - if err != nil { - ctx.ServerError("LoadProtectedBranch", err) - return nil - } - ctx.Data["EnableStatusCheck"] = pb != nil && pb.EnableStatusCheck - - var baseGitRepo *git.Repository - if pull.BaseRepoID == ctx.Repo.Repository.ID && ctx.Repo.GitRepo != nil { - baseGitRepo = ctx.Repo.GitRepo - } else { - baseGitRepo, err := gitrepo.OpenRepository(ctx, pull.BaseRepo) - if err != nil { - ctx.ServerError("OpenRepository", err) - return nil - } - defer baseGitRepo.Close() - } - - statusCheckData := &pullCommitStatusCheckData{} - +func (prInfo *pullRequestViewInfo) prepareViewOpenPullInfo(ctx *context.Context) { + pull := prInfo.issue.PullRequest if exist, _ := git_model.IsBranchExist(ctx, pull.BaseRepo.ID, pull.BaseBranch); !exist { + // if base branch doesn't exist, prepare from the merge base ctx.Data["BaseBranchNotExist"] = true - ctx.Data["IsPullRequestBroken"] = true - ctx.Data["BaseTarget"] = pull.BaseBranch - ctx.Data["HeadTarget"] = pull.HeadBranch - - sha, err := baseGitRepo.GetRefCommitID(pull.GetGitHeadRefName()) - if err != nil { - ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitHeadRefName()), err) - return nil - } - commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) - if err != nil { - ctx.ServerError("GetLatestCommitStatus", err) - return nil - } - if !ctx.Repo.CanRead(unit.TypeActions) { - git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) - } - - statusCheckData.LatestCommitStatus = git_model.CalcCommitStatus(commitStatuses) - if len(commitStatuses) > 0 { - ctx.Data["LatestCommitStatuses"] = commitStatuses - ctx.Data["LatestCommitStatus"] = statusCheckData.LatestCommitStatus - } - - compareInfo, err := git_service.GetCompareInfo(ctx, pull.BaseRepo, pull.BaseRepo, baseGitRepo, - git.RefName(pull.MergeBase), git.RefName(pull.GetGitHeadRefName()), false, false) - if err != nil { - if gitcmd.IsStdErrorNotValidObjectName(err) { - ctx.Data["IsPullRequestBroken"] = true - ctx.Data["BaseTarget"] = pull.BaseBranch - ctx.Data["NumCommits"] = 0 - ctx.Data["NumFiles"] = 0 - return nil - } - - ctx.ServerError("GetCompareInfo", err) - return nil - } - - ctx.Data["NumCommits"] = len(compareInfo.Commits) - ctx.Data["NumFiles"] = compareInfo.NumFiles - return compareInfo + prInfo.prepareViewFillInfo(ctx, git.RefName(pull.MergeBase)) + return } - headBranchSha, headBranchExist, err := getViewPullHeadBranchInfo(ctx, pull, baseGitRepo) - if err != nil { - ctx.ServerError("getViewPullHeadBranchInfo", err) - return nil + prInfo.prepareViewFillInfo(ctx, git.RefNameFromBranch(pull.BaseBranch)) + if ctx.Written() { + return } - if headBranchExist { - var err error - ctx.Data["UpdateAllowed"], ctx.Data["UpdateByRebaseAllowed"], err = pull_service.IsUserAllowedToUpdate(ctx, pull, ctx.Doer) - if err != nil { - ctx.ServerError("IsUserAllowedToUpdate", err) - return nil - } - ctx.Data["GetCommitMessages"] = pull_service.GetSquashMergeCommitMessages(ctx, pull) - } else { - ctx.Data["GetCommitMessages"] = "" - } + ctx.Data["PullHeadCommitID"] = prInfo.CompareInfo.HeadCommitID - sha, err := baseGitRepo.GetRefCommitID(pull.GetGitHeadRefName()) - if err != nil { - if git.IsErrNotExist(err) { - ctx.Data["IsPullRequestBroken"] = true - if pull.IsSameRepo() { - ctx.Data["HeadTarget"] = pull.HeadBranch - } else if pull.HeadRepo == nil { - ctx.Data["HeadTarget"] = ctx.Locale.Tr("repo.pull.deleted_branch", pull.HeadBranch) - } else { - ctx.Data["HeadTarget"] = pull.HeadRepo.OwnerName + ":" + pull.HeadBranch - } - ctx.Data["BaseTarget"] = pull.BaseBranch - ctx.Data["NumCommits"] = 0 - ctx.Data["NumFiles"] = 0 - return nil - } - ctx.ServerError(fmt.Sprintf("GetRefCommitID(%s)", pull.GetGitHeadRefName()), err) - return nil - } - - ctx.Data["StatusCheckData"] = statusCheckData - statusCheckData.ApproveLink = fmt.Sprintf("%s/actions/approve-all-checks?commit_id=%s", repo.Link(), sha) - - commitStatuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, db.ListOptionsAll) - if err != nil { - ctx.ServerError("GetLatestCommitStatus", err) - return nil - } - if !ctx.Repo.CanRead(unit.TypeActions) { - git_model.CommitStatusesHideActionsURL(ctx, commitStatuses) - } - - runs, err := actions_service.GetRunsFromCommitStatuses(ctx, commitStatuses) - if err != nil { - ctx.ServerError("GetRunsFromCommitStatuses", err) - return nil - } - for _, run := range runs { - if run.NeedApproval { - statusCheckData.RequireApprovalRunCount++ - } - } - if statusCheckData.RequireApprovalRunCount > 0 { - statusCheckData.CanApprove = ctx.Repo.CanWrite(unit.TypeActions) - } - - statusCheckData.LatestCommitStatus = git_model.CalcCommitStatus(commitStatuses) - if len(commitStatuses) > 0 { - ctx.Data["LatestCommitStatuses"] = commitStatuses - ctx.Data["LatestCommitStatus"] = statusCheckData.LatestCommitStatus - } - - if pb != nil && pb.EnableStatusCheck { - var missingRequiredChecks []string - for _, requiredContext := range pb.StatusCheckContexts { - contextFound := false - matchesRequiredContext := createRequiredContextMatcher(requiredContext) - for _, presentStatus := range commitStatuses { - if matchesRequiredContext(presentStatus.Context) { - contextFound = true - break - } - } - - if !contextFound { - missingRequiredChecks = append(missingRequiredChecks, requiredContext) - } - } - statusCheckData.MissingRequiredChecks = missingRequiredChecks - - statusCheckData.IsContextRequired = func(context string) bool { - for _, c := range pb.StatusCheckContexts { - if c == context { - return true - } - if gp, err := glob.Compile(c); err != nil { - // All newly created status_check_contexts are checked to ensure they are valid glob expressions before being stored in the database. - // But some old status_check_context created before glob was introduced may be invalid glob expressions. - // So log the error here for debugging. - log.Error("compile glob %q: %v", c, err) - } else if gp.Match(context) { - return true - } - } - return false - } - statusCheckData.RequiredChecksState = pull_service.MergeRequiredContextsCommitStatus(commitStatuses, pb.StatusCheckContexts) - } - - ctx.Data["HeadBranchMovedOn"] = headBranchSha != sha - ctx.Data["HeadBranchCommitID"] = headBranchSha - ctx.Data["PullHeadCommitID"] = sha - - if pull.HeadRepo == nil || !headBranchExist || (!pull.Issue.IsClosed && (headBranchSha != sha)) { - ctx.Data["IsPullRequestBroken"] = true - if pull.IsSameRepo() { - ctx.Data["HeadTarget"] = pull.HeadBranch - } else if pull.HeadRepo == nil { - ctx.Data["HeadTarget"] = ctx.Locale.Tr("repo.pull.deleted_branch", pull.HeadBranch) - } else { - ctx.Data["HeadTarget"] = pull.HeadRepo.OwnerName + ":" + pull.HeadBranch - } - } - - compareInfo, err := git_service.GetCompareInfo(ctx, pull.BaseRepo, pull.BaseRepo, baseGitRepo, - git.RefNameFromBranch(pull.BaseBranch), git.RefName(pull.GetGitHeadRefName()), false, false) - if err != nil { - if gitcmd.IsStdErrorNotValidObjectName(err) { - ctx.Data["IsPullRequestBroken"] = true - ctx.Data["BaseTarget"] = pull.BaseBranch - ctx.Data["NumCommits"] = 0 - ctx.Data["NumFiles"] = 0 - return nil - } - - ctx.ServerError("GetCompareInfo", err) - return nil - } - - if compareInfo.HeadCommitID == compareInfo.MergeBase { + if prInfo.CompareInfo.HeadCommitID == prInfo.CompareInfo.MergeBase { ctx.Data["IsNothingToCompare"] = true } + // this one is used by both sidebar and merge-box if pull.IsWorkInProgress(ctx) { ctx.Data["IsPullWorkInProgress"] = true ctx.Data["WorkInProgressPrefix"] = pull.GetWorkInProgressPrefix(ctx) } - - if pull.IsFilesConflicted() { - ctx.Data["IsPullFilesConflicted"] = true - ctx.Data["ConflictedFiles"] = pull.ConflictedFiles - } - - ctx.Data["NumCommits"] = len(compareInfo.Commits) - ctx.Data["NumFiles"] = compareInfo.NumFiles - return compareInfo } func createRequiredContextMatcher(requiredContext string) func(string) bool { @@ -671,11 +604,13 @@ func ViewPullCommits(ctx *context.Context) { if !ok { return } - - prInfo := preparePullViewPullInfo(ctx, issue) + prViewInfo := newPullRequestViewInfo() + prViewInfo.prepareViewInfo(ctx, issue) if ctx.Written() { return - } else if prInfo == nil { + } + prCompareInfo := &prViewInfo.CompareInfo + if prCompareInfo.HeadCommitID == "" { ctx.NotFound(nil) return } @@ -683,7 +618,7 @@ func ViewPullCommits(ctx *context.Context) { ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name - commits, err := processGitCommits(ctx, prInfo.Commits) + commits, err := processGitCommits(ctx, prCompareInfo.Commits) if err != nil { ctx.ServerError("processGitCommits", err) return @@ -691,7 +626,7 @@ func ViewPullCommits(ctx *context.Context) { ctx.Data["Commits"] = commits ctx.Data["CommitCount"] = len(commits) - ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) // For PR commits page @@ -699,7 +634,6 @@ func ViewPullCommits(ctx *context.Context) { if ctx.Written() { return } - getBranchData(ctx, issue) ctx.HTML(http.StatusOK, tplPullCommits) } @@ -725,46 +659,45 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { gitRepo := ctx.Repo.GitRepo - prInfo := preparePullViewPullInfo(ctx, issue) + prViewInfo := newPullRequestViewInfo() + prViewInfo.prepareViewInfo(ctx, issue) if ctx.Written() { return - } else if prInfo == nil { + } + prCompareInfo := &prViewInfo.CompareInfo + if prCompareInfo.HeadCommitID == "" { ctx.NotFound(nil) return } - headCommitID, err := gitRepo.GetRefCommitID(pull.GetGitHeadRefName()) - if err != nil { - ctx.ServerError("GetRefCommitID", err) - return - } - + headCommitID := prCompareInfo.HeadCommitID isSingleCommit := beforeCommitID == "" && afterCommitID != "" ctx.Data["IsShowingOnlySingleCommit"] = isSingleCommit - isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prInfo.MergeBase) && (afterCommitID == "" || afterCommitID == headCommitID) + isShowAllCommits := (beforeCommitID == "" || beforeCommitID == prCompareInfo.MergeBase) && (afterCommitID == "" || afterCommitID == headCommitID) ctx.Data["IsShowingAllCommits"] = isShowAllCommits if afterCommitID == "" || afterCommitID == headCommitID { afterCommitID = headCommitID } - afterCommit := indexCommit(prInfo.Commits, afterCommitID) + afterCommit := indexCommit(prCompareInfo.Commits, afterCommitID) if afterCommit == nil { ctx.HTTPError(http.StatusBadRequest, "after commit not found in PR commits") return } var beforeCommit *git.Commit + var err error if !isSingleCommit { - if beforeCommitID == "" || beforeCommitID == prInfo.MergeBase { - beforeCommitID = prInfo.MergeBase - // mergebase commit is not in the list of the pull request commits + if beforeCommitID == "" || beforeCommitID == prCompareInfo.MergeBase { + beforeCommitID = prCompareInfo.MergeBase + // merge base commit is not in the list of the pull request commits beforeCommit, err = gitRepo.GetCommit(beforeCommitID) if err != nil { ctx.ServerError("GetCommit", err) return } } else { - beforeCommit = indexCommit(prInfo.Commits, beforeCommitID) + beforeCommit = indexCommit(prCompareInfo.Commits, beforeCommitID) if beforeCommit == nil { ctx.HTTPError(http.StatusBadRequest, "before commit not found in PR commits") return @@ -779,9 +712,7 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { beforeCommitID = beforeCommit.ID.String() } - ctx.Data["Username"] = ctx.Repo.Owner.Name - ctx.Data["Reponame"] = ctx.Repo.Repository.Name - ctx.Data["MergeBase"] = prInfo.MergeBase + ctx.Data["MergeBase"] = prCompareInfo.MergeBase ctx.Data["AfterCommitID"] = afterCommitID ctx.Data["BeforeCommitID"] = beforeCommitID @@ -856,17 +787,12 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { return } - pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) - if err != nil { - ctx.ServerError("LoadProtectedBranch", err) - return - } - - if pb != nil { - glob := pb.GetProtectedFilePatterns() - if len(glob) != 0 { + pb := prViewInfo.ProtectedBranchRule + if prViewInfo.ProtectedBranchRule != nil { + protectedFilePatterns := pb.GetProtectedFilePatterns() + if len(protectedFilePatterns) != 0 { for _, file := range diff.Files { - file.IsProtected = pb.IsProtectedFile(glob, file.Name) + file.IsProtected = pb.IsProtectedFile(protectedFilePatterns, file.Name) } } } @@ -899,11 +825,9 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { } ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 - if ctx.IsSigned && ctx.Doer != nil { - if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil { - ctx.ServerError("CanMarkConversation", err) - return - } + if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, issue, ctx.Doer); err != nil { + ctx.ServerError("CanMarkConversation", err) + return } setCompareContext(ctx, beforeCommit, afterCommit, ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) @@ -935,9 +859,8 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) { ctx.Data["CurrentReview"] = currentReview ctx.Data["PendingCodeCommentNumber"] = numPendingCodeComments - getBranchData(ctx, issue) - ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) - ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + ctx.Data["IsIssuePoster"] = ctx.Doer != nil && issue.IsPoster(ctx.Doer.ID) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled // For files changed page @@ -1042,7 +965,7 @@ func UpdatePullRequest(ctx *context.Context) { flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.pulls.merge_conflict"), "Summary": ctx.Tr("repo.pulls.merge_conflict_summary"), - "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), + "Details": utils.EscapeFlashErrorString(conflictError.StdErr) + "\n" + utils.EscapeFlashErrorString(conflictError.StdOut), }) if err != nil { ctx.ServerError("UpdatePullRequest.HTMLString", err) @@ -1054,9 +977,9 @@ func UpdatePullRequest(ctx *context.Context) { } else if pull_service.IsErrRebaseConflicts(err) { conflictError := err.(pull_service.ErrRebaseConflicts) flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ - "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.SanitizeFlashErrorString(conflictError.CommitSHA)), + "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.EscapeFlashErrorString(conflictError.CommitSHA)), "Summary": ctx.Tr("repo.pulls.rebase_conflict_summary"), - "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), + "Details": utils.EscapeFlashErrorString(conflictError.StdErr) + "\n" + utils.EscapeFlashErrorString(conflictError.StdOut), }) if err != nil { ctx.ServerError("UpdatePullRequest.HTMLString", err) @@ -1100,7 +1023,7 @@ func MergePullRequest(ctx *context.Context) { } // start with merging by checking - if err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil { + if err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, repo_model.MergeStyle(form.Do), form.ForceMerge); err != nil { switch { case errors.Is(err, pull_service.ErrIsClosed): if issue.IsPull { @@ -1120,6 +1043,8 @@ func MergePullRequest(ctx *context.Context) { ctx.JSONError(ctx.Tr("repo.pulls.no_merge_not_ready")) case asymkey_service.IsErrWontSign(err): ctx.JSONError(err.Error()) // has no translation ... + case errors.Is(err, pull_service.ErrHeadCommitsNotAllVerified): + ctx.JSONError(ctx.Tr("repo.pulls.require_signed_head_commits_unverified")) case errors.Is(err, pull_service.ErrDependenciesLeft): ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) default: @@ -1191,7 +1116,7 @@ func MergePullRequest(ctx *context.Context) { flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.editor.merge_conflict"), "Summary": ctx.Tr("repo.editor.merge_conflict_summary"), - "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), + "Details": utils.EscapeFlashErrorString(conflictError.StdErr) + "\n" + utils.EscapeFlashErrorString(conflictError.StdOut), }) if err != nil { ctx.ServerError("MergePullRequest.HTMLString", err) @@ -1202,9 +1127,9 @@ func MergePullRequest(ctx *context.Context) { } else if pull_service.IsErrRebaseConflicts(err) { conflictError := err.(pull_service.ErrRebaseConflicts) flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ - "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.SanitizeFlashErrorString(conflictError.CommitSHA)), + "Message": ctx.Tr("repo.pulls.rebase_conflict", utils.EscapeFlashErrorString(conflictError.CommitSHA)), "Summary": ctx.Tr("repo.pulls.rebase_conflict_summary"), - "Details": utils.SanitizeFlashErrorString(conflictError.StdErr) + "
" + utils.SanitizeFlashErrorString(conflictError.StdOut), + "Details": utils.EscapeFlashErrorString(conflictError.StdErr) + "\n" + utils.EscapeFlashErrorString(conflictError.StdOut), }) if err != nil { ctx.ServerError("MergePullRequest.HTMLString", err) @@ -1234,7 +1159,7 @@ func MergePullRequest(ctx *context.Context) { flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.pulls.push_rejected"), "Summary": ctx.Tr("repo.pulls.push_rejected_summary"), - "Details": utils.SanitizeFlashErrorString(pushrejErr.Message), + "Details": utils.EscapeFlashErrorString(pushrejErr.Message), }) if err != nil { ctx.ServerError("MergePullRequest.HTMLString", err) @@ -1355,7 +1280,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled upload.AddUploadContext(ctx, "comment") - ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.CanWrite(unit.TypePullRequests) + ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypePullRequests) var ( repo = ctx.Repo.Repository @@ -1454,7 +1379,7 @@ func CompareAndPullRequestPost(ctx *context.Context) { flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{ "Message": ctx.Tr("repo.pulls.push_rejected"), "Summary": ctx.Tr("repo.pulls.push_rejected_summary"), - "Details": utils.SanitizeFlashErrorString(pushrejErr.Message), + "Details": utils.EscapeFlashErrorString(pushrejErr.Message), }) if err != nil { ctx.ServerError("CompareAndPullRequest.HTMLString", err) @@ -1550,7 +1475,7 @@ func UpdatePullRequestTarget(ctx *context.Context) { return } - if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) { + if !ctx.IsSigned || (!issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull)) { ctx.HTTPError(http.StatusForbidden) return } diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index f064058221e..eb8e8fa677e 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -72,7 +72,7 @@ func CreateCodeComment(ctx *context.Context) { } if ctx.HasError() { - ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Flash.Error(ctx.GetErrMsg()) ctx.Redirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) return } @@ -230,7 +230,7 @@ func SubmitReview(ctx *context.Context) { return } if ctx.HasError() { - ctx.Flash.Error(ctx.Data["ErrorMsg"].(string)) + ctx.Flash.Error(ctx.GetErrMsg()) ctx.JSONRedirect(fmt.Sprintf("%s/pulls/%d/files", ctx.Repo.RepoLink, issue.Index)) return } diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 1372022ae4a..288e107e53b 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -99,18 +99,14 @@ func getReleaseInfos(ctx *context.Context, opts *repo_model.FindReleasesOptions) } var ok bool - canReadActions := ctx.Repo.CanRead(unit.TypeActions) + canReadActions := ctx.Repo.Permission.CanRead(unit.TypeActions) releaseInfos := make([]*ReleaseInfo, 0, len(releases)) for _, r := range releases { if r.Publisher, ok = cacheUsers[r.PublisherID]; !ok { - r.Publisher, err = user_model.GetPossibleUserByID(ctx, r.PublisherID) + r.PublisherID, r.Publisher, err = user_model.GetPossibleUserByID(ctx, r.PublisherID) if err != nil { - if user_model.IsErrUserNotExist(err) { - r.Publisher = user_model.NewGhostUser() - } else { - return nil, err - } + return nil, err } cacheUsers[r.PublisherID] = r.Publisher } @@ -165,7 +161,7 @@ func Releases(ctx *context.Context) { listOptions.PageSize = setting.API.MaxResponseItems } - writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) + writeAccess := ctx.Repo.Permission.CanWrite(unit.TypeReleases) ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ @@ -197,7 +193,7 @@ func Releases(ctx *context.Context) { func TagsList(ctx *context.Context) { ctx.Data["PageIsTagList"] = true ctx.Data["Title"] = ctx.Tr("repo.release.tags") - ctx.Data["CanCreateRelease"] = ctx.Repo.CanWrite(unit.TypeReleases) && !ctx.Repo.Repository.IsArchived + ctx.Data["CanCreateRelease"] = ctx.Repo.Permission.CanWrite(unit.TypeReleases) && !ctx.Repo.Repository.IsArchived namePattern := ctx.FormTrim("q") @@ -274,7 +270,7 @@ func releasesOrTagsFeed(ctx *context.Context, isReleasesOnly bool, formatType st func SingleRelease(ctx *context.Context) { ctx.Data["PageIsReleaseList"] = true - writeAccess := ctx.Repo.CanWrite(unit.TypeReleases) + writeAccess := ctx.Repo.Permission.CanWrite(unit.TypeReleases) ctx.Data["CanCreateRelease"] = writeAccess && !ctx.Repo.Repository.IsArchived releases, err := getReleaseInfos(ctx, &repo_model.FindReleasesOptions{ diff --git a/routers/web/repo/release_test.go b/routers/web/repo/release_test.go index 7ba91afb297..d57886c2738 100644 --- a/routers/web/repo/release_test.go +++ b/routers/web/repo/release_test.go @@ -151,7 +151,7 @@ func TestCalReleaseNumCommitsBehind(t *testing.T) { t.Cleanup(func() { ctx.Repo.GitRepo.Close() }) releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ - IncludeDrafts: ctx.Repo.CanWrite(unit.TypeReleases), + IncludeDrafts: ctx.Repo.Permission.CanWrite(unit.TypeReleases), RepoID: ctx.Repo.Repository.ID, }) assert.NoError(t, err) diff --git a/routers/web/repo/render.go b/routers/web/repo/render.go index 160f6315855..ace871a9f18 100644 --- a/routers/web/repo/render.go +++ b/routers/web/repo/render.go @@ -43,7 +43,8 @@ func RenderFile(ctx *context.Context) { CurrentRefPath: ctx.Repo.RefTypeNameSubURL(), CurrentTreePath: path.Dir(ctx.Repo.TreePath), }).WithRelativePath(ctx.Repo.TreePath).WithStandalonePage(markup.StandalonePageOptions{ - CurrentWebTheme: ctx.TemplateContext.CurrentWebTheme(), + CurrentWebTheme: ctx.TemplateContext.CurrentWebTheme(), + RenderQueryString: ctx.Req.URL.RawQuery, }) renderer, rendererInput, err := rctx.DetectMarkupRendererByReader(blobReader) if err != nil { diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 57937be83e2..c7813feae23 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -302,12 +302,12 @@ func CreatePost(ctx *context.Context) { func handleActionError(ctx *context.Context, err error) { switch { case errors.Is(err, user_model.ErrBlockedUser): - ctx.Flash.Error(ctx.Tr("repo.action.blocked_user")) + ctx.JSONError(ctx.Tr("repo.action.blocked_user")) case repo_service.IsRepositoryLimitReached(err): limit := err.(repo_service.LimitReachedError).Limit - ctx.Flash.Error(ctx.TrN(limit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", limit)) + ctx.JSONError(ctx.TrN(limit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", limit)) case errors.Is(err, util.ErrPermissionDenied): - ctx.HTTPError(http.StatusNotFound) + ctx.JSONError(ctx.Tr("error.permission_denied")) default: ctx.ServerError(fmt.Sprintf("Action (%s)", ctx.PathParam("action")), err) } @@ -322,7 +322,7 @@ func RedirectDownload(ctx *context.Context) { tagNames := []string{vTag} curRepo := ctx.Repo.Repository releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{ - IncludeDrafts: ctx.Repo.CanWrite(unit.TypeReleases), + IncludeDrafts: ctx.Repo.Permission.CanWrite(unit.TypeReleases), RepoID: curRepo.ID, TagNames: tagNames, }) @@ -532,7 +532,7 @@ func SearchRepo(ctx *context.Context) { ctx.JSON(http.StatusInternalServerError, nil) return } - if !ctx.Repo.CanRead(unit.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { git_model.CommitStatusesHideActionsURL(ctx, latestCommitStatuses) } diff --git a/routers/web/repo/setting/collaboration.go b/routers/web/repo/setting/collaboration.go index dbfd6e08b61..177d10d165c 100644 --- a/routers/web/repo/setting/collaboration.go +++ b/routers/web/repo/setting/collaboration.go @@ -149,7 +149,7 @@ func DeleteCollaboration(ctx *context.Context) { // AddTeamPost response for adding a team to a repository func AddTeamPost(ctx *context.Context) { - if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() { + if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.Permission.IsOwner() { ctx.Flash.Error(ctx.Tr("repo.settings.change_team_access_not_allowed")) ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") return @@ -195,7 +195,7 @@ func AddTeamPost(ctx *context.Context) { // DeleteTeam response for deleting a team from a repository func DeleteTeam(ctx *context.Context) { - if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() { + if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.Permission.IsOwner() { ctx.Flash.Error(ctx.Tr("repo.settings.change_team_access_not_allowed")) ctx.Redirect(ctx.Repo.RepoLink + "/settings/collaboration") return diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 5a5137a1a74..703d0022504 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -459,11 +459,7 @@ func handleSettingsPostPushMirrorAdd(ctx *context.Context) { return } - remoteSuffix, err := util.CryptoRandomString(10) - if err != nil { - ctx.ServerError("RandomString", err) - return - } + remoteSuffix := util.CryptoRandomString(10) remoteAddress, err := util.SanitizeURL(form.PushMirrorAddress) if err != nil { @@ -531,7 +527,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) { } if form.EnableWiki && form.EnableExternalWiki && !unit_model.TypeExternalWiki.UnitGlobalDisabled() { - if !validation.IsValidExternalURL(form.ExternalWikiURL) { + if !validation.IsValidURL(form.ExternalWikiURL) { ctx.Flash.Error(ctx.Tr("repo.settings.external_wiki_url_error")) ctx.Redirect(repo.Link() + "/settings") return @@ -561,7 +557,7 @@ func handleSettingsPostAdvanced(ctx *context.Context) { } if form.EnableIssues && form.EnableExternalTracker && !unit_model.TypeExternalTracker.UnitGlobalDisabled() { - if !validation.IsValidExternalURL(form.ExternalTrackerURL) { + if !validation.IsValidURL(form.ExternalTrackerURL) { ctx.Flash.Error(ctx.Tr("repo.settings.external_tracker_url_error")) ctx.Redirect(repo.Link() + "/settings") return @@ -728,17 +724,17 @@ func handleSettingsPostAdminIndex(ctx *context.Context) { func handleSettingsPostConvert(ctx *context.Context) { form := web.GetForm(ctx).(*forms.RepoSettingForm) repo := ctx.Repo.Repository - if !ctx.Repo.IsOwner() { - ctx.HTTPError(http.StatusNotFound) + if !ctx.Repo.Permission.IsOwner() { + ctx.JSONErrorNotFound() return } if repo.Name != form.RepoName { - ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("form.enterred_invalid_repo_name")) return } if !repo.IsMirror { - ctx.HTTPError(http.StatusNotFound) + ctx.JSONErrorNotFound() return } repo.IsMirror = false @@ -752,14 +748,14 @@ func handleSettingsPostConvert(ctx *context.Context) { } log.Trace("Repository converted from mirror to regular: %s", repo.FullName()) ctx.Flash.Success(ctx.Tr("repo.settings.convert_succeed")) - ctx.Redirect(repo.Link()) + ctx.JSONRedirect(repo.Link()) } func handleSettingsPostConvertFork(ctx *context.Context) { form := web.GetForm(ctx).(*forms.RepoSettingForm) repo := ctx.Repo.Repository - if !ctx.Repo.IsOwner() { - ctx.HTTPError(http.StatusNotFound) + if !ctx.Repo.Permission.IsOwner() { + ctx.JSONErrorNotFound() return } if err := repo.LoadOwner(ctx); err != nil { @@ -767,12 +763,12 @@ func handleSettingsPostConvertFork(ctx *context.Context) { return } if repo.Name != form.RepoName { - ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("form.enterred_invalid_repo_name")) return } if !repo.IsFork { - ctx.HTTPError(http.StatusNotFound) + ctx.JSONErrorNotFound() return } @@ -780,7 +776,7 @@ func handleSettingsPostConvertFork(ctx *context.Context) { maxCreationLimit := ctx.Repo.Owner.MaxCreationLimit() msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) ctx.Flash.Error(msg) - ctx.Redirect(repo.Link() + "/settings") + ctx.JSONRedirect(repo.Link() + "/settings") return } @@ -792,25 +788,25 @@ func handleSettingsPostConvertFork(ctx *context.Context) { log.Trace("Repository converted from fork to regular: %s", repo.FullName()) ctx.Flash.Success(ctx.Tr("repo.settings.convert_fork_succeed")) - ctx.Redirect(repo.Link()) + ctx.JSONRedirect(repo.Link()) } func handleSettingsPostTransfer(ctx *context.Context) { form := web.GetForm(ctx).(*forms.RepoSettingForm) repo := ctx.Repo.Repository - if !ctx.Repo.IsOwner() { - ctx.HTTPError(http.StatusNotFound) + if !ctx.Repo.Permission.IsOwner() { + ctx.JSONErrorNotFound() return } if repo.Name != form.RepoName { - ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("form.enterred_invalid_repo_name")) return } newOwner, err := user_model.GetUserByName(ctx, ctx.FormString("new_owner_name")) if err != nil { if user_model.IsErrUserNotExist(err) { - ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("form.enterred_invalid_owner_name")) return } ctx.ServerError("IsUserExist", err) @@ -820,7 +816,7 @@ func handleSettingsPostTransfer(ctx *context.Context) { if newOwner.Type == user_model.UserTypeOrganization { if !ctx.Doer.IsAdmin && newOwner.Visibility == structs.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) { // The user shouldn't know about this organization - ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_owner_name"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("form.enterred_invalid_owner_name")) return } } @@ -834,14 +830,14 @@ func handleSettingsPostTransfer(ctx *context.Context) { oldFullname := repo.FullName() if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, repo, nil); err != nil { if repo_model.IsErrRepoAlreadyExist(err) { - ctx.RenderWithErrDeprecated(ctx.Tr("repo.settings.new_owner_has_same_repo"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("repo.settings.new_owner_has_same_repo")) } else if repo_model.IsErrRepoTransferInProgress(err) { - ctx.RenderWithErrDeprecated(ctx.Tr("repo.settings.transfer_in_progress"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("repo.settings.transfer_in_progress")) } else if repo_service.IsRepositoryLimitReached(err) { limit := err.(repo_service.LimitReachedError).Limit - ctx.RenderWithErrDeprecated(ctx.TrN(limit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", limit), tplSettingsOptions, nil) + ctx.JSONError(ctx.TrN(limit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", limit)) } else if errors.Is(err, user_model.ErrBlockedUser) { - ctx.RenderWithErrDeprecated(ctx.Tr("repo.settings.transfer.blocked_user"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("repo.settings.transfer.blocked_user")) } else { ctx.ServerError("TransferOwnership", err) } @@ -856,12 +852,12 @@ func handleSettingsPostTransfer(ctx *context.Context) { log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName()) ctx.Flash.Success(ctx.Tr("repo.settings.transfer_succeed")) } - ctx.Redirect(repo.Link() + "/settings") + ctx.JSONRedirect(repo.Link() + "/settings") } func handleSettingsPostCancelTransfer(ctx *context.Context) { repo := ctx.Repo.Repository - if !ctx.Repo.IsOwner() { + if !ctx.Repo.Permission.IsOwner() { ctx.HTTPError(http.StatusNotFound) return } @@ -890,12 +886,12 @@ func handleSettingsPostCancelTransfer(ctx *context.Context) { func handleSettingsPostDelete(ctx *context.Context) { form := web.GetForm(ctx).(*forms.RepoSettingForm) repo := ctx.Repo.Repository - if !ctx.Repo.IsOwner() { - ctx.HTTPError(http.StatusNotFound) + if !ctx.Repo.Permission.IsOwner() { + ctx.JSONErrorNotFound() return } if repo.Name != form.RepoName { - ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("form.enterred_invalid_repo_name")) return } @@ -911,18 +907,18 @@ func handleSettingsPostDelete(ctx *context.Context) { log.Trace("Repository deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) ctx.Flash.Success(ctx.Tr("repo.settings.deletion_success")) - ctx.Redirect(ctx.Repo.Owner.DashboardLink()) + ctx.JSONRedirect(ctx.Repo.Owner.DashboardLink()) } func handleSettingsPostDeleteWiki(ctx *context.Context) { form := web.GetForm(ctx).(*forms.RepoSettingForm) repo := ctx.Repo.Repository - if !ctx.Repo.IsOwner() { - ctx.HTTPError(http.StatusNotFound) + if !ctx.Repo.Permission.IsOwner() { + ctx.JSONErrorNotFound() return } if repo.Name != form.RepoName { - ctx.RenderWithErrDeprecated(ctx.Tr("form.enterred_invalid_repo_name"), tplSettingsOptions, nil) + ctx.JSONError(ctx.Tr("form.enterred_invalid_repo_name")) return } @@ -933,12 +929,12 @@ func handleSettingsPostDeleteWiki(ctx *context.Context) { log.Trace("Repository wiki deleted: %s/%s", ctx.Repo.Owner.Name, repo.Name) ctx.Flash.Success(ctx.Tr("repo.settings.wiki_deletion_success")) - ctx.Redirect(ctx.Repo.RepoLink + "/settings") + ctx.JSONRedirect(ctx.Repo.RepoLink + "/settings") } func handleSettingsPostArchive(ctx *context.Context) { repo := ctx.Repo.Repository - if !ctx.Repo.IsOwner() { + if !ctx.Repo.Permission.IsOwner() { ctx.HTTPError(http.StatusForbidden) return } @@ -971,7 +967,7 @@ func handleSettingsPostArchive(ctx *context.Context) { func handleSettingsPostUnarchive(ctx *context.Context) { repo := ctx.Repo.Repository - if !ctx.Repo.IsOwner() { + if !ctx.Repo.Permission.IsOwner() { ctx.HTTPError(http.StatusForbidden) return } diff --git a/routers/web/repo/setting/webhook.go b/routers/web/repo/setting/webhook.go index b0f3a5cfee8..8c57a68b250 100644 --- a/routers/web/repo/setting/webhook.go +++ b/routers/web/repo/setting/webhook.go @@ -450,12 +450,21 @@ func MatrixHooksEditPost(ctx *context.Context) { editWebhook(ctx, matrixHookParams(ctx)) } +func matrixRoomIDEncode(roomID string) string { + // See https://spec.matrix.org/latest/appendices/#room-ids + // Some (unrelated) demo links: https://spec.matrix.org/latest/appendices/#matrixto-navigation + // API spec: https://spec.matrix.org/v1.18/client-server-api/#sending-events-to-a-room + // Some of their examples show links like: "PUT /rooms/!roomid:domain/state/m.example.event" + return strings.NewReplacer("%21", "!", "%3A", ":").Replace(url.PathEscape(roomID)) +} + func matrixHookParams(ctx *context.Context) webhookParams { form := web.GetForm(ctx).(*forms.NewMatrixHookForm) + // TODO: need to migrate to the latest (v3) API: https://spec.matrix.org/v1.18/client-server-api/ return webhookParams{ Type: webhook_module.MATRIX, - URL: fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, url.PathEscape(form.RoomID)), + URL: fmt.Sprintf("%s/_matrix/client/r0/rooms/%s/send/m.room.message", form.HomeserverURL, matrixRoomIDEncode(form.RoomID)), ContentType: webhook.ContentTypeJSON, HTTPMethod: http.MethodPut, WebhookForm: form.WebhookForm, diff --git a/routers/web/repo/setting/webhook_test.go b/routers/web/repo/setting/webhook_test.go new file mode 100644 index 00000000000..ca4a21e0755 --- /dev/null +++ b/routers/web/repo/setting/webhook_test.go @@ -0,0 +1,15 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWebhookMatrix(t *testing.T) { + assert.Equal(t, "!roomid:domain", matrixRoomIDEncode("!roomid:domain")) + assert.Equal(t, "!room%23id:domain", matrixRoomIDEncode("!room#id:domain")) // maybe it should never really happen in real world +} diff --git a/routers/web/repo/star.go b/routers/web/repo/star.go index 00c06b7d02d..8cfbfefdf14 100644 --- a/routers/web/repo/star.go +++ b/routers/web/repo/star.go @@ -26,6 +26,5 @@ func ActionStar(ctx *context.Context) { ctx.ServerError("GetRepositoryByName", err) return } - ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl ctx.HTML(http.StatusOK, tplStarUnstar) } diff --git a/routers/web/repo/transfer.go b/routers/web/repo/transfer.go index 5553eee6741..a606e0343f1 100644 --- a/routers/web/repo/transfer.go +++ b/routers/web/repo/transfer.go @@ -12,7 +12,7 @@ func acceptTransfer(ctx *context.Context) { err := repo_service.AcceptTransferOwnership(ctx, ctx.Repo.Repository, ctx.Doer) if err == nil { ctx.Flash.Success(ctx.Tr("repo.settings.transfer.success")) - ctx.Redirect(ctx.Repo.Repository.Link()) + ctx.JSONRedirect(ctx.Repo.Repository.Link()) return } handleActionError(ctx, err) @@ -22,7 +22,7 @@ func rejectTransfer(ctx *context.Context) { err := repo_service.RejectRepositoryTransfer(ctx, ctx.Repo.Repository, ctx.Doer) if err == nil { ctx.Flash.Success(ctx.Tr("repo.settings.transfer.rejected")) - ctx.Redirect(ctx.Repo.Repository.Link()) + ctx.JSONRedirect(ctx.Repo.Repository.Link()) return } handleActionError(ctx, err) diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 46661f0df0f..2d95d5233e3 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -16,10 +16,6 @@ import ( "strings" "time" - _ "image/gif" // for processing gif images - _ "image/jpeg" // for processing jpeg images - _ "image/png" // for processing png images - activities_model "code.gitea.io/gitea/models/activities" admin_model "code.gitea.io/gitea/models/admin" asymkey_model "code.gitea.io/gitea/models/asymkey" @@ -46,6 +42,9 @@ import ( _ "golang.org/x/image/bmp" // for processing bmp images _ "golang.org/x/image/webp" // for processing webp images + _ "image/gif" // for processing gif images + _ "image/jpeg" // for processing jpeg images + _ "image/png" // for processing png images ) const ( @@ -140,7 +139,7 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { if err != nil { log.Error("GetLatestCommitStatus: %v", err) } - if !ctx.Repo.CanRead(unit_model.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit_model.TypeActions) { git_model.CommitStatusesHideActionsURL(ctx, statuses) } @@ -175,12 +174,11 @@ func markupRenderToHTML(ctx *context.Context, renderCtx *markup.RenderContext, r } func checkHomeCodeViewable(ctx *context.Context) { - if ctx.Repo.HasUnits() { + if ctx.Repo.Permission.HasUnits() { if ctx.Repo.Repository.IsBeingCreated() { task, err := admin_model.GetMigratingTask(ctx, ctx.Repo.Repository.ID) if err != nil { if admin_model.IsErrTaskDoesNotExist(err) { - ctx.Data["Repo"] = ctx.Repo ctx.Data["CloneAddr"] = "" ctx.Data["Failed"] = true ctx.HTML(http.StatusOK, tplMigrating) @@ -195,7 +193,6 @@ func checkHomeCodeViewable(ctx *context.Context) { return } - ctx.Data["Repo"] = ctx.Repo ctx.Data["MigrateTask"] = task ctx.Data["CloneAddr"], _ = util.SanitizeURL(cfg.CloneAddr) ctx.Data["Failed"] = task.Status == structs.TaskStatusFailed @@ -310,13 +307,15 @@ func renderDirectoryFiles(ctx *context.Context, timeout time.Duration) git.Entri return nil } - { + { // this block is for testing purpose only if timeout != 0 && !setting.IsProd && !setting.IsInTesting { log.Debug("first call to get directory file commit info") clearFilesCommitInfo := func() { log.Warn("clear directory file commit info to force async loading on frontend") for i := range files { - files[i].Commit = nil + if i%2 == 0 { // for testing purpose, only clear half of the files' commit info + files[i].Commit = nil + } } } _ = clearFilesCommitInfo diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go index 3ae0dab25b8..8d7721103a3 100644 --- a/routers/web/repo/view_file.go +++ b/routers/web/repo/view_file.go @@ -21,12 +21,11 @@ import ( "code.gitea.io/gitea/modules/git/attribute" "code.gitea.io/gitea/modules/highlight" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" - - "github.com/nektos/act/pkg/model" ) func prepareLatestCommitInfo(ctx *context.Context) bool { @@ -78,14 +77,17 @@ func handleFileViewRenderMarkup(ctx *context.Context, prefetchBuf []byte, utf8Re return false } - ctx.Data["MarkupType"] = rctx.RenderOptions.MarkupType - var err error ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRenderToHTML(ctx, rctx, renderer, utf8Reader) if err != nil { ctx.ServerError("Render", err) return true } + + opts, ok := markup.GetExternalRendererOptions(renderer) + usingIframe := ok && opts.DisplayInIframe + ctx.Data["MarkupType"] = rctx.RenderOptions.MarkupType + ctx.Data["RenderAsMarkup"] = util.Iif(usingIframe, "markup-iframe", "markup-inplace") return true } @@ -184,8 +186,7 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) { if err != nil { log.Error("actions.GetContentFromEntry: %v", err) } - _, workFlowErr := model.ReadWorkflow(bytes.NewReader(content)) - if workFlowErr != nil { + if workFlowErr := actions.ValidateWorkflowContent(content); workFlowErr != nil { ctx.Data["FileError"] = ctx.Locale.Tr("actions.runs.invalid_workflow_helper", workFlowErr.Error()) } } else if issue_service.IsCodeOwnerFile(ctx.Repo.TreePath) { @@ -238,8 +239,6 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) { case fInfo.blobOrLfsSize >= setting.UI.MaxDisplayFileSize: ctx.Data["IsFileTooLarge"] = true case handleFileViewRenderMarkup(ctx, buf, contentReader): - // it also sets ctx.Data["FileContent"] and more - ctx.Data["IsMarkup"] = true case handleFileViewRenderSource(ctx, attrs, fInfo, contentReader): // it also sets ctx.Data["FileContent"] and more ctx.Data["IsDisplayingSource"] = true diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index d1a969cf2d7..cbdc65b6c0b 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -31,7 +31,7 @@ import ( ) func checkOutdatedBranch(ctx *context.Context) { - if !(ctx.Repo.IsAdmin() || ctx.Repo.IsOwner()) { + if !(ctx.Repo.Permission.IsAdmin() || ctx.Repo.Permission.IsOwner()) { return } diff --git a/routers/web/repo/view_readme.go b/routers/web/repo/view_readme.go index eba3ffc36fd..25e1f87806c 100644 --- a/routers/web/repo/view_readme.go +++ b/routers/web/repo/view_readme.go @@ -195,16 +195,16 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil }).WithRelativePath(readmeFullPath) renderer := rctx.DetectMarkupRenderer(buf) if renderer != nil { - ctx.Data["IsMarkup"] = true + ctx.Data["RenderAsMarkup"] = "markup-inplace" ctx.Data["MarkupType"] = rctx.RenderOptions.MarkupType ctx.Data["EscapeStatus"], ctx.Data["FileContent"], err = markupRenderToHTML(ctx, rctx, renderer, rd) if err != nil { log.Error("Render failed for %s in %-v: %v Falling back to rendering source", readmeFile.Name(), ctx.Repo.Repository, err) - delete(ctx.Data, "IsMarkup") + delete(ctx.Data, "RenderAsMarkup") } } - if ctx.Data["IsMarkup"] != true { + if ctx.Data["RenderAsMarkup"] == nil { ctx.Data["IsPlainText"] = true content, err := io.ReadAll(rd) if err != nil { diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go index 70c548b8cea..a7fbfc168be 100644 --- a/routers/web/repo/watch.go +++ b/routers/web/repo/watch.go @@ -26,6 +26,5 @@ func ActionWatch(ctx *context.Context) { ctx.ServerError("GetRepositoryByName", err) return } - ctx.RespHeader().Add("hx-trigger", "refreshUserCards") // see the `hx-trigger="refreshUserCards ..."` comments in tmpl ctx.HTML(http.StatusOK, tplWatchUnwatch) } diff --git a/routers/web/repo/wiki.go b/routers/web/repo/wiki.go index 1826ca54e1e..39075dbdf6f 100644 --- a/routers/web/repo/wiki.go +++ b/routers/web/repo/wiki.go @@ -47,8 +47,8 @@ const ( // MustEnableWiki check if wiki is enabled, if external then redirect func MustEnableWiki(ctx *context.Context) { - if !ctx.Repo.CanRead(unit.TypeWiki) && - !ctx.Repo.CanRead(unit.TypeExternalWiki) { + if !ctx.Repo.Permission.CanRead(unit.TypeWiki) && + !ctx.Repo.Permission.CanRead(unit.TypeExternalWiki) { if log.IsTrace() { log.Trace("Permission Denied: User %-v cannot read %-v or %-v of repo %-v\n"+ "User in repo has Permissions: %-+v", @@ -423,14 +423,14 @@ func renderEditPage(ctx *context.Context) { func WikiPost(ctx *context.Context) { switch ctx.FormString("action") { case "_new": - if !ctx.Repo.CanWrite(unit.TypeWiki) { + if !ctx.Repo.Permission.CanWrite(unit.TypeWiki) { ctx.NotFound(nil) return } NewWikiPost(ctx) return case "_delete": - if !ctx.Repo.CanWrite(unit.TypeWiki) { + if !ctx.Repo.Permission.CanWrite(unit.TypeWiki) { ctx.NotFound(nil) return } @@ -438,7 +438,7 @@ func WikiPost(ctx *context.Context) { return } - if !ctx.Repo.CanWrite(unit.TypeWiki) { + if !ctx.Repo.Permission.CanWrite(unit.TypeWiki) { ctx.NotFound(nil) return } @@ -447,7 +447,7 @@ func WikiPost(ctx *context.Context) { // Wiki renders single wiki page func Wiki(ctx *context.Context) { - ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived + ctx.Data["CanWriteWiki"] = ctx.Repo.Permission.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived switch ctx.FormString("action") { case "_pages": @@ -457,14 +457,14 @@ func Wiki(ctx *context.Context) { WikiRevision(ctx) return case "_edit": - if !ctx.Repo.CanWrite(unit.TypeWiki) { + if !ctx.Repo.Permission.CanWrite(unit.TypeWiki) { ctx.NotFound(nil) return } EditWiki(ctx) return case "_new": - if !ctx.Repo.CanWrite(unit.TypeWiki) { + if !ctx.Repo.Permission.CanWrite(unit.TypeWiki) { ctx.NotFound(nil) return } @@ -506,7 +506,7 @@ func Wiki(ctx *context.Context) { // WikiRevision renders file revision list of wiki page func WikiRevision(ctx *context.Context) { - ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived + ctx.Data["CanWriteWiki"] = ctx.Repo.Permission.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived if !repo_service.HasWiki(ctx, ctx.Repo.Repository) { ctx.Data["Title"] = ctx.Tr("repo.wiki") @@ -544,7 +544,7 @@ func WikiPages(ctx *context.Context) { } ctx.Data["Title"] = ctx.Tr("repo.wiki.pages") - ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived + ctx.Data["CanWriteWiki"] = ctx.Repo.Permission.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived _, commit, err := findWikiRepoCommit(ctx) if err != nil { diff --git a/routers/web/shared/actions/runners.go b/routers/web/shared/actions/runners.go index 3609258440c..9d8b2f08eb1 100644 --- a/routers/web/shared/actions/runners.go +++ b/routers/web/shared/actions/runners.go @@ -362,10 +362,6 @@ func RunnerUpdatePost(ctx *context.Context) { ctx.JSONRedirect("") } -func RedirectToDefaultSetting(ctx *context.Context) { - ctx.Redirect(ctx.Repo.RepoLink + "/settings/actions/runners") -} - func findActionsRunner(ctx *context.Context, rCtx *runnersCtx) *actions_model.ActionRunner { runnerID := ctx.PathParamInt64("runnerid") opts := &actions_model.FindRunnerOptions{ diff --git a/routers/web/shared/label/label.go b/routers/web/shared/label/label.go index 6968a318c47..4a3c84e32ad 100644 --- a/routers/web/shared/label/label.go +++ b/routers/web/shared/label/label.go @@ -13,7 +13,7 @@ import ( func GetLabelEditForm(ctx *context.Context) *forms.CreateLabelForm { form := web.GetForm(ctx).(*forms.CreateLabelForm) if ctx.HasError() { - ctx.JSONError(ctx.Data["ErrorMsg"].(string)) + ctx.JSONError(ctx.GetErrMsg()) return nil } var err error diff --git a/routers/web/shared/secrets/secrets.go b/routers/web/shared/secrets/secrets.go index 29f4e9520dc..c8842a67e90 100644 --- a/routers/web/shared/secrets/secrets.go +++ b/routers/web/shared/secrets/secrets.go @@ -29,7 +29,7 @@ func SetSecretsContext(ctx *context.Context, ownerID, repoID int64) { func PerformSecretsPost(ctx *context.Context, ownerID, repoID int64, redirectURL string) { form := web.GetForm(ctx).(*forms.AddSecretForm) - s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.ReserveLineBreakForTextarea(form.Data), form.Description) + s, _, err := secret_service.CreateOrUpdateSecret(ctx, ownerID, repoID, form.Name, util.NormalizeStringEOL(form.Data), form.Description) if err != nil { log.Error("CreateOrUpdateSecret failed: %v", err) ctx.JSONError(ctx.Tr("secrets.save_failed")) diff --git a/routers/web/shared/user/block.go b/routers/web/shared/user/block.go index 8a2357623f1..beb8832da17 100644 --- a/routers/web/shared/user/block.go +++ b/routers/web/shared/user/block.go @@ -7,6 +7,8 @@ import ( "errors" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" @@ -28,49 +30,51 @@ func BlockedUsers(ctx *context.Context, blocker *user_model.User) { ctx.Data["UserBlocks"] = blocks } -func BlockedUsersPost(ctx *context.Context, blocker *user_model.User) { - form := web.GetForm(ctx).(*forms.BlockUserForm) - if ctx.HasError() { - ctx.ServerError("FormValidation", nil) - return - } - +func blockedUsersPost(ctx *context.Context, form *forms.BlockUserForm, blocker *user_model.User) error { blockee, err := user_model.GetUserByName(ctx, form.Blockee) if err != nil { - ctx.ServerError("GetUserByName", nil) - return + return err } switch form.Action { case "block": - if err := user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, form.Note); err != nil { - if errors.Is(err, user_model.ErrCanNotBlock) || errors.Is(err, user_model.ErrBlockOrganization) { - ctx.Flash.Error(ctx.Tr("user.block.block.failure", err.Error())) - } else { - ctx.ServerError("BlockUser", err) - return - } + err = user_service.BlockUser(ctx, ctx.Doer, blocker, blockee, form.Note) + if errors.Is(err, util.ErrInvalidArgument) { + return util.ErrorWrapTranslatable(err, "user.block.block.failure", err.Error()) } + return err case "unblock": - if err := user_service.UnblockUser(ctx, ctx.Doer, blocker, blockee); err != nil { - if errors.Is(err, user_model.ErrCanNotUnblock) || errors.Is(err, user_model.ErrBlockOrganization) { - ctx.Flash.Error(ctx.Tr("user.block.unblock.failure", err.Error())) - } else { - ctx.ServerError("UnblockUser", err) - return - } + err = user_service.UnblockUser(ctx, ctx.Doer, blocker, blockee) + if errors.Is(err, util.ErrInvalidArgument) { + return util.ErrorWrapTranslatable(err, "user.block.unblock.failure", err.Error()) } + return err case "note": block, err := user_model.GetBlocking(ctx, blocker.ID, blockee.ID) if err != nil { - ctx.ServerError("GetBlocking", err) - return - } - if block != nil { - if err := user_model.UpdateBlockingNote(ctx, block.ID, form.Note); err != nil { - ctx.ServerError("UpdateBlockingNote", err) - return - } + return err } + return user_model.UpdateBlockingNote(ctx, block.ID, form.Note) + } + setting.PanicInDevOrTesting("Unknown action: %q", form.Action) + return errors.New("unknown action") +} + +func BlockedUsersPost(ctx *context.Context, blocker *user_model.User, redirect string) { + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + form := web.GetForm(ctx).(*forms.BlockUserForm) + err := blockedUsersPost(ctx, form, blocker) + if err == nil { + ctx.JSONRedirect(redirect) + } else if errTr := util.ErrorAsTranslatable(err); errTr != nil { + ctx.JSONError(errTr.Translate(ctx.Locale)) + } else if errors.Is(err, util.ErrNotExist) { + ctx.JSONError(ctx.Locale.Tr("error.not_found")) + } else { + ctx.ServerError("BlockedUsersPost", err) } } diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 2ba45fc5a7d..b1ff9db7a28 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -4,6 +4,7 @@ package user import ( + "errors" "net/url" "code.gitea.io/gitea/models/db" @@ -83,10 +84,9 @@ func prepareContextForProfileBigAvatar(ctx *context.Context) { } if ctx.Doer != nil { - if block, err := user_model.GetBlocking(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + ctx.Data["UserBlocking"], err = user_model.GetBlocking(ctx, ctx.Doer.ID, ctx.ContextUser.ID) + if err != nil && !errors.Is(err, util.ErrNotExist) { ctx.ServerError("GetBlocking", err) - } else { - ctx.Data["UserBlocking"] = block } } } diff --git a/routers/web/user/home.go b/routers/web/user/home.go index 21ca0fc683e..12fb1ee71b0 100644 --- a/routers/web/user/home.go +++ b/routers/web/user/home.go @@ -558,7 +558,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) { ctx.ServerError("GetIssuesLastCommitStatus", err) return } - if !ctx.Repo.CanRead(unit.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { for key := range commitStatuses { git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) } @@ -655,6 +655,9 @@ func ShowSSHKeys(ctx *context.Context) { // "authorized_keys" file format: "#" followed by comment line per key buf.WriteString("# Gitea isn't a key server. The keys are exported as the user uploaded and might not have been fully verified.\n") for i := range keys { + if keys[i].Type == asymkey_model.KeyTypePrincipal { + continue // SSH principal keys are not for signing or authentication + } buf.WriteString(keys[i].OmitEmail()) buf.WriteString("\n") } diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index 3b7ecd062b3..8133388c5de 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -247,7 +247,7 @@ func NotificationSubscriptions(ctx *context.Context) { ctx.ServerError("GetIssuesAllCommitStatus", err) return } - if !ctx.Repo.CanRead(unit.TypeActions) { + if !ctx.Repo.Permission.CanRead(unit.TypeActions) { for key := range commitStatuses { git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key]) } diff --git a/routers/web/user/setting/block.go b/routers/web/user/setting/block.go index 3a1625ccf9b..1376c0417d0 100644 --- a/routers/web/user/setting/block.go +++ b/routers/web/user/setting/block.go @@ -29,10 +29,5 @@ func BlockedUsers(ctx *context.Context) { } func BlockedUsersPost(ctx *context.Context) { - shared_user.BlockedUsersPost(ctx, ctx.Doer) - if ctx.Written() { - return - } - - ctx.Redirect(setting.AppSubURL + "/user/settings/blocked_users") + shared_user.BlockedUsersPost(ctx, ctx.Doer, setting.AppSubURL+"/user/settings/blocked_users") } diff --git a/routers/web/web.go b/routers/web/web.go index 61d1fdc1421..d70eb2d02d5 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/modules/web/routing" + "code.gitea.io/gitea/modules/web/types" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/routers/web/admin" "code.gitea.io/gitea/routers/web/auth" @@ -45,7 +46,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/forms" - _ "code.gitea.io/gitea/modules/session" // to registers all internal adapters + _ "code.gitea.io/gitea/modules/session" // to register all internal adapters "gitea.com/go-chi/captcha" chi_middleware "github.com/go-chi/chi/v5/middleware" @@ -91,8 +92,8 @@ func optionsCorsHandler() func(next http.Handler) http.Handler { } type AuthMiddleware struct { - AllowOAuth2 web.PreMiddlewareProvider - AllowBasic web.PreMiddlewareProvider + AllowOAuth2 types.PreMiddlewareProvider + AllowBasic types.PreMiddlewareProvider MiddlewareHandler func(*context.Context) } @@ -101,7 +102,7 @@ func newWebAuthMiddleware() *AuthMiddleware { type keyAllowBasic struct{} webAuth := &AuthMiddleware{} - middlewareSetContextValue := func(key, val any) web.PreMiddlewareProvider { + middlewareSetContextValue := func(key, val any) types.PreMiddlewareProvider { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dataStore := reqctx.GetRequestDataStore(r.Context()) @@ -260,6 +261,7 @@ func Routes() *web.Router { routes.BeforeRouting(chi_middleware.GetHead) routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler + routes.Methods("GET, HEAD", "/assets/site-manifest.json", misc.SiteManifest) routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, public.AssetsCors(), public.FileHandlerFunc()) routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars)) routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars)) @@ -587,7 +589,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }) }, reqSignOut) - m.Any("/user/events", routing.MarkLongPolling, events.Events) + m.Any("/user/events", routing.MarkLongPolling(), events.Events) m.Group("/login/oauth", func() { m.Group("", func() { @@ -1355,6 +1357,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone) m.Post("/projects", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProject) + m.Post("/projects/column", reqRepoIssuesOrPullsWriter, reqRepoProjectsWriter, repo.UpdateIssueProjectColumn) m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) @@ -1538,6 +1541,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Combo(""). Get(actions.View). Post(web.Bind(actions.ViewRequest{}), actions.ViewPost) + m.Group("/attempts/{attempt}", func() { + m.Combo(""). + Get(actions.View). + Post(web.Bind(actions.ViewRequest{}), actions.ViewPost) + }) m.Group("/jobs/{job}", func() { m.Combo(""). Get(actions.View). @@ -1710,7 +1718,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/forks", repo.Forks) m.Get("/commit/{sha:([a-f0-9]{7,64})}.{ext:patch|diff}", repo.MustBeNotEmpty, repo.RawDiff) - m.Post("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit) + m.Get("/lastcommit/*", context.RepoRefByType(git.RefTypeCommit), repo.LastCommit) }, optSignIn, context.RepoAssignment, reqUnitCodeReader) // end "/{username}/{reponame}": repo code @@ -1753,8 +1761,10 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Any("/mail-preview/*", devtest.MailPreviewRender) m.Any("/{sub}", devtest.TmplCommon) m.Get("/repo-action-view/runs/{run}", devtest.MockActionsView) + m.Get("/repo-action-view/runs/{run}/attempts/{attempt}", devtest.MockActionsView) m.Get("/repo-action-view/runs/{run}/jobs/{job}", devtest.MockActionsView) m.Post("/repo-action-view/runs/{run}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) + m.Post("/repo-action-view/runs/{run}/attempts/{attempt}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) m.Post("/repo-action-view/runs/{run}/jobs/{job}", web.Bind(actions.ViewRequest{}), devtest.MockActionsRunsJobs) }) } diff --git a/services/actions/approve.go b/services/actions/approve.go new file mode 100644 index 00000000000..552b055b706 --- /dev/null +++ b/services/actions/approve.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" +) + +func ApproveRuns(ctx context.Context, repo *repo_model.Repository, doer *user_model.User, runIDs []int64) error { + updatedJobs := make([]*actions_model.ActionRunJob, 0) + cancelledConcurrencyJobs := make([]*actions_model.ActionRunJob, 0) + + err := db.WithTx(ctx, func(ctx context.Context) (err error) { + for _, runID := range runIDs { + run, err := actions_model.GetRunByRepoAndID(ctx, repo.ID, runID) + if err != nil { + return err + } + run.NeedApproval = false + run.ApprovedBy = doer.ID + if err := actions_model.UpdateRun(ctx, run, "need_approval", "approved_by"); err != nil { + return err + } + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, repo.ID, run.ID) + if err != nil { + return err + } + for _, job := range jobs { + // Skip jobs with `needs`: they stay blocked until their dependencies finish, + // at which point job_emitter will evaluate and start them. + if len(job.Needs) > 0 { + continue + } + var jobsToCancel []*actions_model.ActionRunJob + job.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, job) + if err != nil { + return err + } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) + if job.Status == actions_model.StatusWaiting { + n, err := actions_model.UpdateRunJob(ctx, job, nil, "status") + if err != nil { + return err + } + if n > 0 { + updatedJobs = append(updatedJobs, job) + } + } + } + } + return nil + }) + if err != nil { + return err + } + + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs) + + EmitJobsIfReadyByJobs(cancelledConcurrencyJobs) + + return nil +} diff --git a/services/actions/cleanup.go b/services/actions/cleanup.go index d0cc63e5388..f223c981255 100644 --- a/services/actions/cleanup.go +++ b/services/actions/cleanup.go @@ -179,7 +179,7 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error { repoID := run.RepoID - jobs, err := actions_model.GetRunJobsByRunID(ctx, run.ID) + jobs, err := actions_model.GetAllRunJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { return err } @@ -207,6 +207,10 @@ func DeleteRun(ctx context.Context, run *actions_model.ActionRun) error { RepoID: repoID, ID: run.ID, }) + recordsToDelete = append(recordsToDelete, &actions_model.ActionRunAttempt{ + RepoID: repoID, + RunID: run.ID, + }) recordsToDelete = append(recordsToDelete, &actions_model.ActionRunJob{ RepoID: repoID, RunID: run.ID, diff --git a/services/actions/clear_tasks.go b/services/actions/clear_tasks.go index c71f63e7d17..940f1d84544 100644 --- a/services/actions/clear_tasks.go +++ b/services/actions/clear_tasks.go @@ -17,7 +17,6 @@ import ( "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" - notify_service "code.gitea.io/gitea/services/notify" ) // StopZombieTasks stops the task which have running status, but haven't been updated for a long time @@ -36,39 +35,16 @@ func StopEndlessTasks(ctx context.Context) error { }) } -func notifyWorkflowJobStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) { - if len(jobs) == 0 { - return - } - // The input jobs may belong to different runs, so track each affected run. - runs := make(map[int64]*actions_model.ActionRun, len(jobs)) - for _, job := range jobs { - if err := job.LoadAttributes(ctx); err != nil { - log.Error("Failed to load job attributes: %v", err) - continue - } - CreateCommitStatusForRunJobs(ctx, job.Run, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - if _, ok := runs[job.RunID]; !ok { - runs[job.RunID] = job.Run - } - } - - for _, run := range runs { - notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) - } -} - func CancelPreviousJobs(ctx context.Context, repoID int64, ref, workflowID string, event webhook_module.HookEventType) error { jobs, err := actions_model.CancelPreviousJobs(ctx, repoID, ref, workflowID, event) - notifyWorkflowJobStatusUpdate(ctx, jobs) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs) EmitJobsIfReadyByJobs(jobs) return err } func CleanRepoScheduleTasks(ctx context.Context, repo *repo_model.Repository) error { jobs, err := actions_model.CleanRepoScheduleTasks(ctx, repo) - notifyWorkflowJobStatusUpdate(ctx, jobs) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs) EmitJobsIfReadyByJobs(jobs) return err } @@ -83,61 +59,59 @@ func shouldBlockJobByConcurrency(ctx context.Context, job *actions_model.ActionR return false, nil } - runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning}) + attempts, jobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, job.RepoID, job.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning}) if err != nil { - return false, fmt.Errorf("GetConcurrentRunsAndJobs: %w", err) + return false, fmt.Errorf("GetConcurrentRunAttemptsAndJobs: %w", err) } - return len(runs) > 0 || len(jobs) > 0, nil + return len(attempts) > 0 || len(jobs) > 0, nil } // PrepareToStartJobWithConcurrency prepares a job to start by its evaluated concurrency group and cancelling previous jobs if necessary. -// It returns the new status of the job (either StatusBlocked or StatusWaiting) and any error encountered during the process. -func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (actions_model.Status, error) { +// It returns the new status of the job (either StatusBlocked or StatusWaiting), any cancelled jobs, and any error encountered during the process. +func PrepareToStartJobWithConcurrency(ctx context.Context, job *actions_model.ActionRunJob) (actions_model.Status, []*actions_model.ActionRunJob, error) { shouldBlock, err := shouldBlockJobByConcurrency(ctx, job) if err != nil { - return actions_model.StatusBlocked, err + return actions_model.StatusBlocked, nil, err } // even if the current job is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group jobs, err := actions_model.CancelPreviousJobsByJobConcurrency(ctx, job) if err != nil { - return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByJobConcurrency: %w", err) + return actions_model.StatusBlocked, nil, fmt.Errorf("CancelPreviousJobsByJobConcurrency: %w", err) } - notifyWorkflowJobStatusUpdate(ctx, jobs) - return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil + return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), jobs, nil } -func shouldBlockRunByConcurrency(ctx context.Context, actionRun *actions_model.ActionRun) (bool, error) { - if actionRun.ConcurrencyGroup == "" || actionRun.ConcurrencyCancel { +func shouldBlockRunByConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (bool, error) { + if attempt.ConcurrencyGroup == "" || attempt.ConcurrencyCancel { return false, nil } - runs, jobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, actionRun.RepoID, actionRun.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning}) + attempts, jobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, attempt.RepoID, attempt.ConcurrencyGroup, []actions_model.Status{actions_model.StatusRunning}) if err != nil { return false, fmt.Errorf("find concurrent runs and jobs: %w", err) } - return len(runs) > 0 || len(jobs) > 0, nil + return len(attempts) > 0 || len(jobs) > 0, nil } -// PrepareToStartRunWithConcurrency prepares a run to start by its evaluated concurrency group and cancelling previous jobs if necessary. -// It returns the new status of the run (either StatusBlocked or StatusWaiting) and any error encountered during the process. -func PrepareToStartRunWithConcurrency(ctx context.Context, run *actions_model.ActionRun) (actions_model.Status, error) { - shouldBlock, err := shouldBlockRunByConcurrency(ctx, run) +// PrepareToStartRunWithConcurrency prepares a run attempt to start by its evaluated concurrency group and cancelling previous jobs if necessary. +// It returns the new status of the run attempt (either StatusBlocked or StatusWaiting), any cancelled jobs, and any error encountered during the process. +func PrepareToStartRunWithConcurrency(ctx context.Context, attempt *actions_model.ActionRunAttempt) (actions_model.Status, []*actions_model.ActionRunJob, error) { + shouldBlock, err := shouldBlockRunByConcurrency(ctx, attempt) if err != nil { - return actions_model.StatusBlocked, err + return actions_model.StatusBlocked, nil, err } // even if the current run is blocked, we still need to cancel previous "waiting/blocked" jobs in the same concurrency group - jobs, err := actions_model.CancelPreviousJobsByRunConcurrency(ctx, run) + jobs, err := actions_model.CancelPreviousJobsByRunConcurrency(ctx, attempt) if err != nil { - return actions_model.StatusBlocked, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err) + return actions_model.StatusBlocked, nil, fmt.Errorf("CancelPreviousJobsByRunConcurrency: %w", err) } - notifyWorkflowJobStatusUpdate(ctx, jobs) - return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), nil + return util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting), jobs, nil } func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { @@ -175,7 +149,7 @@ func stopTasks(ctx context.Context, opts actions_model.FindTaskOptions) error { remove() } - notifyWorkflowJobStatusUpdate(ctx, jobs) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, jobs) EmitJobsIfReadyByJobs(jobs) return nil @@ -194,8 +168,6 @@ func CancelAbandonedJobs(ctx context.Context) error { now := timeutil.TimeStampNow() - // Collect one job per run to send workflow run status update - updatedRuns := map[int64]*actions_model.ActionRunJob{} updatedJobs := []*actions_model.ActionRunJob{} for _, job := range jobs { @@ -211,9 +183,6 @@ func CancelAbandonedJobs(ctx context.Context) error { return err } updated = n > 0 - if updated && job.Run.Status.IsDone() { - updatedRuns[job.RunID] = job - } return nil }); err != nil { log.Warn("cancel abandoned job %v: %v", job.ID, err) @@ -222,16 +191,13 @@ func CancelAbandonedJobs(ctx context.Context) error { if job.Run == nil || job.Run.Repo == nil { continue // error occurs during loading attributes, the following code that depends on "Run.Repo" will fail, so ignore and skip } - CreateCommitStatusForRunJobs(ctx, job.Run, job) if updated { + CreateCommitStatusForRunJobs(ctx, job.Run, job) updatedJobs = append(updatedJobs, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) } } - for _, job := range updatedRuns { - notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) - } + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, updatedJobs) EmitJobsIfReadyByJobs(updatedJobs) return nil diff --git a/services/actions/commit_status.go b/services/actions/commit_status.go index 95b848f4fb1..5a7f8f1f443 100644 --- a/services/actions/commit_status.go +++ b/services/actions/commit_status.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "path" - "strconv" "strings" actions_model "code.gitea.io/gitea/models/actions" @@ -143,57 +142,59 @@ func createCommitStatus(ctx context.Context, repo *repo_model.Repository, event, if wfs, err := jobparser.Parse(job.WorkflowPayload); err == nil && len(wfs) > 0 { runName = wfs[0].Name } - ctxName := fmt.Sprintf("%s / %s (%s)", runName, job.Name, event) - ctxName = strings.TrimSpace(ctxName) // git_model.NewCommitStatus also trims spaces + ctxName := strings.TrimSpace(fmt.Sprintf("%s / %s (%s)", runName, job.Name, event)) // git_model.NewCommitStatus also trims spaces state := toCommitStatus(job.Status) - if statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll); err == nil { - for _, v := range statuses { - if v.Context == ctxName { - if v.State == state { - // no need to update - return nil - } - break - } - } - } else { + targetURL := fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID) + description := toCommitStatusDescription(job) + + statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, commitID, db.ListOptionsAll) + if err != nil { return fmt.Errorf("GetLatestCommitStatus: %w", err) } - - var description string - switch job.Status { - // TODO: if we want support description in different languages, we need to support i18n placeholders in it - case actions_model.StatusSuccess: - description = fmt.Sprintf("Successful in %s", job.Duration()) - case actions_model.StatusFailure: - description = fmt.Sprintf("Failing after %s", job.Duration()) - case actions_model.StatusCancelled: - description = "Has been cancelled" - case actions_model.StatusSkipped: - description = "Has been skipped" - case actions_model.StatusRunning: - description = "Has started running" - case actions_model.StatusWaiting: - description = "Waiting to run" - case actions_model.StatusBlocked: - description = "Blocked by required conditions" - default: - description = "Unknown status: " + strconv.Itoa(int(job.Status)) + for _, v := range statuses { + if v.Context == ctxName { + if v.State == state && v.TargetURL == targetURL && v.Description == description { + return nil + } + break + } } creator := user_model.NewActionsUser() status := git_model.CommitStatus{ SHA: commitID, - TargetURL: fmt.Sprintf("%s/jobs/%d", run.Link(), job.ID), + TargetURL: targetURL, Description: description, Context: ctxName, - CreatorID: creator.ID, State: state, + CreatorID: creator.ID, } return commitstatus_service.CreateCommitStatus(ctx, repo, creator, commitID, &status) } +func toCommitStatusDescription(job *actions_model.ActionRunJob) string { + switch job.Status { + // TODO: if we want support description in different languages, we need to support i18n placeholders in it + case actions_model.StatusSuccess: + return fmt.Sprintf("Successful in %s", job.Duration()) + case actions_model.StatusFailure: + return fmt.Sprintf("Failing after %s", job.Duration()) + case actions_model.StatusCancelled: + return "Has been cancelled" + case actions_model.StatusSkipped: + return "Has been skipped" + case actions_model.StatusRunning: + return "Has started running" + case actions_model.StatusWaiting: + return "Waiting to run" + case actions_model.StatusBlocked: + return "Blocked by required conditions" + default: + return fmt.Sprintf("Unknown status: %d", job.Status) + } +} + func toCommitStatus(status actions_model.Status) commitstatus.CommitStatusState { switch status { case actions_model.StatusSuccess: diff --git a/services/actions/commit_status_test.go b/services/actions/commit_status_test.go new file mode 100644 index 00000000000..6ff93933189 --- /dev/null +++ b/services/actions/commit_status_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/commitstatus" + "code.gitea.io/gitea/modules/gitrepo" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateCommitStatus_Dedupe(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4}) + gitRepo, err := gitrepo.OpenRepository(t.Context(), repo) + require.NoError(t, err) + defer gitRepo.Close() + + commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch) + require.NoError(t, err) + + run := &actions_model.ActionRun{ + ID: 99001, + RepoID: repo.ID, + Repo: repo, + WorkflowID: "status-dedupe-test.yaml", + } + job := &actions_model.ActionRunJob{ + ID: 99002, + RunID: run.ID, + RepoID: repo.ID, + Name: "status-dedupe-job", + Status: actions_model.StatusWaiting, + } + + expectedContext := "status-dedupe-test.yaml / status-dedupe-job (push)" + expectedTargetURL := run.Link() + "/jobs/99002" + + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + + statuses := findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) + require.Len(t, statuses, 1) + assert.Equal(t, commitstatus.CommitStatusPending, statuses[0].State) + assert.Equal(t, "Waiting to run", statuses[0].Description) + assert.Equal(t, expectedTargetURL, statuses[0].TargetURL) + + job.Status = actions_model.StatusRunning + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + + statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) + require.Len(t, statuses, 2) + assert.Equal(t, "Waiting to run", statuses[0].Description) + assert.Equal(t, commitstatus.CommitStatusPending, statuses[1].State) + assert.Equal(t, "Has started running", statuses[1].Description) + assert.Equal(t, expectedTargetURL, statuses[1].TargetURL) + + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) + assert.Len(t, statuses, 2) + + job.Status = actions_model.StatusSuccess + require.NoError(t, createCommitStatus(t.Context(), repo, "push", commit.ID.String(), run, job)) + statuses = findCommitStatusesForContext(t, repo.ID, commit.ID.String(), expectedContext) + require.Len(t, statuses, 3) + assert.Equal(t, commitstatus.CommitStatusSuccess, statuses[2].State) +} + +func findCommitStatusesForContext(t *testing.T, repoID int64, sha, context string) []*git_model.CommitStatus { + t.Helper() + + var statuses []*git_model.CommitStatus + err := db.GetEngine(t.Context()). + Where("repo_id = ? AND sha = ? AND context = ?", repoID, sha, context). + Asc("`index`"). + Find(&statuses) + require.NoError(t, err) + return statuses +} diff --git a/services/actions/concurrency.go b/services/actions/concurrency.go index 878e5c483bf..e1ec5499309 100644 --- a/services/actions/concurrency.go +++ b/services/actions/concurrency.go @@ -17,15 +17,15 @@ import ( ) // EvaluateRunConcurrencyFillModel evaluates the expressions in a run-level (workflow) concurrency, -// and fills the run's model fields with `concurrency.group` and `concurrency.cancel-in-progress`. +// and fills the run attempt model with the evaluated `concurrency.group` and `concurrency.cancel-in-progress` values. // Workflow-level concurrency doesn't depend on the job outputs, so it can always be evaluated if there is no syntax error. // See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency -func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error { +func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, wfRawConcurrency *act_model.RawConcurrency, vars map[string]string, inputs map[string]any) error { if err := run.LoadAttributes(ctx); err != nil { return fmt.Errorf("run LoadAttributes: %w", err) } - actionsRunCtx := GenerateGiteaContext(run, nil) + actionsRunCtx := GenerateGiteaContext(ctx, run, attempt, nil) jobResults := map[string]*jobparser.JobResult{"": {}} if inputs == nil { var err error @@ -35,12 +35,8 @@ func EvaluateRunConcurrencyFillModel(ctx context.Context, run *actions_model.Act } } - rawConcurrency, err := yaml.Marshal(wfRawConcurrency) - if err != nil { - return fmt.Errorf("marshal raw concurrency: %w", err) - } - run.RawConcurrency = string(rawConcurrency) - run.ConcurrencyGroup, run.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(wfRawConcurrency, "", nil, actionsRunCtx, jobResults, vars, inputs) + var err error + attempt.ConcurrencyGroup, attempt.ConcurrencyCancel, err = jobparser.EvaluateConcurrency(wfRawConcurrency, "", nil, actionsRunCtx, jobResults, vars, inputs) if err != nil { return fmt.Errorf("evaluate concurrency: %w", err) } @@ -71,7 +67,7 @@ func findJobNeedsAndFillJobResults(ctx context.Context, job *actions_model.Actio // Job-level concurrency may depend on other job's outputs (via `needs`): `concurrency.group: my-group-${{ needs.job1.outputs.out1 }}` // If the needed jobs haven't been executed yet, this evaluation will also fail. // See https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idconcurrency -func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, actionRunJob *actions_model.ActionRunJob, vars map[string]string, inputs map[string]any) error { +func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, actionRunJob *actions_model.ActionRunJob, vars map[string]string, inputs map[string]any) error { if err := actionRunJob.LoadAttributes(ctx); err != nil { return fmt.Errorf("job LoadAttributes: %w", err) } @@ -81,7 +77,7 @@ func EvaluateJobConcurrencyFillModel(ctx context.Context, run *actions_model.Act return fmt.Errorf("unmarshal raw concurrency: %w", err) } - actionsJobCtx := GenerateGiteaContext(run, actionRunJob) + actionsJobCtx := GenerateGiteaContext(ctx, run, attempt, actionRunJob) jobResults, err := findJobNeedsAndFillJobResults(ctx, actionRunJob) if err != nil { diff --git a/services/actions/context.go b/services/actions/context.go index 626ae6ee6bf..9250c409835 100644 --- a/services/actions/context.go +++ b/services/actions/context.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" @@ -22,9 +23,14 @@ import ( type GiteaContext map[string]any -// GenerateGiteaContext generate the gitea context without token and gitea_runtime_token -// job can be nil when generating a context for parsing workflow-level expressions -func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.ActionRunJob) GiteaContext { +// GenerateGiteaContext generate the gitea context without token and gitea_runtime_token. +// attempt and job can be nil when generating a context for parsing workflow-level expressions. +// +// The run_attempt value is resolved with the following precedence: +// 1. attempt.Attempt - the explicit attempt argument, or run.GetLatestAttempt() as a fallback +// 2. job.Attempt - only used when neither an explicit nor latest attempt is available +// 3. "1" - when none of the above apply (first-run parse time, before the first attempt exists) +func GenerateGiteaContext(ctx context.Context, run *actions_model.ActionRun, attempt *actions_model.ActionRunAttempt, job *actions_model.ActionRunJob) GiteaContext { event := map[string]any{} _ = json.Unmarshal([]byte(run.EventPayload), &event) @@ -73,7 +79,7 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio "repository_owner": run.Repo.OwnerName, // string, The repository owner's name. For example, Codertocat. "repositoryUrl": run.Repo.HTMLURL(), // string, The Git URL to the repository. For example, git://github.com/codertocat/hello-world.git. "retention_days": "", // string, The number of days that workflow run logs and artifacts are kept. - "run_id": "", // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. + "run_id": strconv.FormatInt(run.ID, 10), // string, A unique number for each workflow run within a repository. This number does not change if you re-run the workflow run. "run_number": strconv.FormatInt(run.Index, 10), // string, A unique number for each run of a particular workflow in a repository. This number begins at 1 for the workflow's first run, and increments with each new run. This number does not change if you re-run the workflow run. "run_attempt": "", // string, A unique number for each attempt of a particular workflow run in a repository. This number begins at 1 for the workflow run's first attempt, and increments with each re-run. "secret_source": "Actions", // string, The source of a secret used in a workflow. Possible values are None, Actions, Dependabot, or Codespaces. @@ -89,10 +95,28 @@ func GenerateGiteaContext(run *actions_model.ActionRun, job *actions_model.Actio if job != nil { gitContext["job"] = job.JobID - gitContext["run_id"] = strconv.FormatInt(job.RunID, 10) gitContext["run_attempt"] = strconv.FormatInt(job.Attempt, 10) } + if attempt == nil { + if latestAttempt, has, err := run.GetLatestAttempt(ctx); err == nil && has { + attempt = latestAttempt + } + } + + if attempt != nil { + gitContext["run_attempt"] = strconv.FormatInt(attempt.Attempt, 10) + if err := attempt.LoadAttributes(ctx); err == nil { + gitContext["triggering_actor"] = attempt.TriggerUser.Name + } + } + + // Fallback for first-run parse time: no job, no attempt (LatestAttemptID==0). github.run_attempt + // is 1-based per the documented contract, so emit "1" rather than leaving it empty. + if gitContext["run_attempt"] == "" { + gitContext["run_attempt"] = "1" + } + return gitContext } @@ -108,7 +132,13 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st } needs := container.SetOf(job.Needs...) - jobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: job.RunID}) + // Scope to the same attempt. For legacy jobs RunAttemptID==0, which matches all other legacy jobs in the same run. + findOpts := actions_model.FindRunJobOptions{ + RunID: job.RunID, + RunAttemptID: optional.Some(job.RunAttemptID), + } + + jobs, err := db.Find[actions_model.ActionRunJob](ctx, findOpts) if err != nil { return nil, fmt.Errorf("FindRunJobs: %w", err) } @@ -125,11 +155,12 @@ func FindTaskNeeds(ctx context.Context, job *actions_model.ActionRunJob) (map[st } var jobOutputs map[string]string for _, job := range jobsWithSameID { - if job.TaskID == 0 || !job.Status.IsDone() { - // it shouldn't happen, or the job has been rerun + taskID := job.EffectiveTaskID() + if taskID == 0 || !job.Status.IsDone() { + // it shouldn't happen continue } - got, err := actions_model.FindTaskOutputByTaskID(ctx, job.TaskID) + got, err := actions_model.FindTaskOutputByTaskID(ctx, taskID) if err != nil { return nil, fmt.Errorf("FindTaskOutputByTaskID: %w", err) } diff --git a/services/actions/context_test.go b/services/actions/context_test.go index 74ef694021a..22f9abcce81 100644 --- a/services/actions/context_test.go +++ b/services/actions/context_test.go @@ -4,14 +4,92 @@ package actions import ( + "strconv" "testing" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/unittest" + act_model "github.com/nektos/act/pkg/model" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestEvaluateRunConcurrency_RunIDFallback(t *testing.T) { + // Unit-level check that EvaluateRunConcurrencyFillModel resolves + // github.run_id from run.ID. The full-flow regression — that run.ID is + // non-zero by the time evaluation happens — is in + // TestPrepareRunAndInsert_ExpressionsSeeRunID. + assert.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + runA := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 791}) + runB := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: 792}) + + attemptA := &actions_model.ActionRunAttempt{RepoID: runA.RepoID, RunID: runA.ID, Attempt: 1} + attemptB := &actions_model.ActionRunAttempt{RepoID: runB.RepoID, RunID: runB.ID, Attempt: 1} + + expr := &act_model.RawConcurrency{ + Group: "${{ github.workflow }}-${{ github.head_ref || github.run_id }}", + CancelInProgress: "true", + } + + assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runA, attemptA, expr, nil, nil)) + assert.NoError(t, EvaluateRunConcurrencyFillModel(ctx, runB, attemptB, expr, nil, nil)) + + assert.Contains(t, attemptA.ConcurrencyGroup, "791") + assert.Contains(t, attemptB.ConcurrencyGroup, "792") + assert.NotEqual(t, attemptA.ConcurrencyGroup, attemptB.ConcurrencyGroup) +} + +func TestPrepareRunAndInsert_ExpressionsSeeRunID(t *testing.T) { + // Regression for the cross-branch concurrency leak: github.run_id must + // be available during BOTH jobparser.Parse (run-name) and workflow-level + // concurrency evaluation. Re-ordering db.Insert relative to either step + // would leave run.ID at 0 and break this test. + assert.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + content := []byte(`name: cross-branch +run-name: "Run ${{ github.run_id }}" +on: push +concurrency: + group: group-${{ github.run_id }} + cancel-in-progress: true +jobs: + hello: + runs-on: ubuntu-latest + steps: + - run: echo hi +`) + + run := &actions_model.ActionRun{ + Title: "before parse", + RepoID: 4, + OwnerID: 1, + WorkflowID: "expr-runid.yaml", + TriggerUserID: 1, + Ref: "refs/heads/master", + CommitSHA: "c2d72f548424103f01ee1dc02889c1e2bff816b0", + Event: "push", + TriggerEvent: "push", + EventPayload: "{}", + } + require.NoError(t, PrepareRunAndInsert(ctx, content, run, nil)) + require.Positive(t, run.ID) + + persisted := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{ID: run.ID}) + runIDStr := strconv.FormatInt(run.ID, 10) + assert.Equal(t, "Run "+runIDStr, persisted.Title) + // ConcurrencyGroup lives on the latest attempt after migration v331. + require.Positive(t, persisted.LatestAttemptID) + attempt := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunAttempt{ID: persisted.LatestAttemptID}) + assert.Equal(t, "group-"+runIDStr, attempt.ConcurrencyGroup) + // Rerun reads raw_concurrency from the DB to re-evaluate the group; + // see services/actions/rerun.go. Must survive the insert. + assert.NotEmpty(t, persisted.RawConcurrency) +} + func TestFindTaskNeeds(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/services/actions/init_test.go b/services/actions/init_test.go index e61b3759e17..4db765839e0 100644 --- a/services/actions/init_test.go +++ b/services/actions/init_test.go @@ -35,7 +35,7 @@ func TestInitToken(t *testing.T) { }) t.Run("EnvToken", func(t *testing.T) { - tokenValue, _ := util.CryptoRandomString(32) + tokenValue := util.CryptoRandomString(32) t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN", tokenValue) t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN_FILE", "") err := initGlobalRunnerToken(t.Context()) @@ -52,7 +52,7 @@ func TestInitToken(t *testing.T) { }) t.Run("EnvFileToken", func(t *testing.T) { - tokenValue, _ := util.CryptoRandomString(32) + tokenValue := util.CryptoRandomString(32) f := t.TempDir() + "/token" _ = os.WriteFile(f, []byte(tokenValue), 0o644) t.Setenv("GITEA_RUNNER_REGISTRATION_TOKEN", "") diff --git a/services/actions/job_emitter.go b/services/actions/job_emitter.go index 20a4f81eabb..489b36a3a7d 100644 --- a/services/actions/job_emitter.go +++ b/services/actions/job_emitter.go @@ -16,7 +16,6 @@ import ( "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" - notify_service "code.gitea.io/gitea/services/notify" "xorm.io/builder" ) @@ -70,30 +69,33 @@ func checkJobsByRunID(ctx context.Context, runID int64) error { if err != nil { return fmt.Errorf("get action run: %w", err) } - var jobs, updatedJobs []*actions_model.ActionRunJob + var jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob if err := db.WithTx(ctx, func(ctx context.Context) error { // check jobs of the current run - if js, ujs, err := checkJobsOfRun(ctx, run); err != nil { + if js, ujs, cjs, err := checkJobsOfCurrentRunAttempt(ctx, run); err != nil { return err } else { jobs = append(jobs, js...) updatedJobs = append(updatedJobs, ujs...) + cancelledJobs = append(cancelledJobs, cjs...) } - if js, ujs, err := checkRunConcurrency(ctx, run); err != nil { + if js, ujs, cjs, err := checkRunConcurrency(ctx, run); err != nil { return err } else { jobs = append(jobs, js...) updatedJobs = append(updatedJobs, ujs...) + cancelledJobs = append(cancelledJobs, cjs...) } return nil }); err != nil { return err } - CreateCommitStatusForRunJobs(ctx, run, jobs...) - for _, job := range updatedJobs { - _ = job.LoadAttributes(ctx) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledJobs) + EmitJobsIfReadyByJobs(cancelledJobs) + if err := createCommitStatusesForJobsByRun(ctx, jobs); err != nil { + return err } + NotifyWorkflowJobsStatusUpdate(ctx, updatedJobs...) runJobs := make(map[int64][]*actions_model.ActionRunJob) for _, job := range jobs { runJobs[job.RunID] = append(runJobs[job.RunID], job) @@ -114,105 +116,130 @@ func checkJobsByRunID(ctx context.Context, runID int64) error { } } if runUpdated { - NotifyWorkflowRunStatusUpdateWithReload(ctx, js[0]) + NotifyWorkflowRunStatusUpdateWithReload(ctx, js[0].RepoID, js[0].RunID) } } return nil } -// findBlockedRunByConcurrency finds the blocked concurrent run in a repo and returns `nil, nil` when there is no blocked run. -func findBlockedRunByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (*actions_model.ActionRun, error) { - if concurrencyGroup == "" { - return nil, nil //nolint:nilnil // return nil to indicate that no blocked run exists - } - cRuns, cJobs, err := actions_model.GetConcurrentRunsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked}) - if err != nil { - return nil, fmt.Errorf("find concurrent runs and jobs: %w", err) +func createCommitStatusesForJobsByRun(ctx context.Context, jobs []*actions_model.ActionRunJob) error { + runJobs := make(map[int64][]*actions_model.ActionRunJob) + for _, job := range jobs { + runJobs[job.RunID] = append(runJobs[job.RunID], job) } - // There can be at most one blocked run or job - var concurrentRun *actions_model.ActionRun - if len(cRuns) > 0 { - concurrentRun = cRuns[0] - } else if len(cJobs) > 0 { - jobRun, exist, err := db.GetByID[actions_model.ActionRun](ctx, cJobs[0].RunID) - if !exist { - return nil, fmt.Errorf("run %d does not exist", cJobs[0].RunID) - } + for jobRunID, jobList := range runJobs { + run, err := actions_model.GetRunByRepoAndID(ctx, jobList[0].RepoID, jobRunID) if err != nil { - return nil, fmt.Errorf("get run by job %d: %w", cJobs[0].ID, err) + return fmt.Errorf("get action run %d: %w", jobRunID, err) } - concurrentRun = jobRun + CreateCommitStatusForRunJobs(ctx, run, jobList...) } - - return concurrentRun, nil + return nil } -func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) { +// findBlockedRunIDByConcurrency finds a blocked concurrent run in a repo and returns 0 when there is no blocked run. +func findBlockedRunIDByConcurrency(ctx context.Context, repoID int64, concurrencyGroup string) (int64, error) { + if concurrencyGroup == "" { + return 0, nil + } + cAttempts, cJobs, err := actions_model.GetConcurrentRunAttemptsAndJobs(ctx, repoID, concurrencyGroup, []actions_model.Status{actions_model.StatusBlocked}) + if err != nil { + return 0, fmt.Errorf("find concurrent runs and jobs: %w", err) + } + + if len(cAttempts) > 0 { + return cAttempts[0].RunID, nil + } + if len(cJobs) > 0 { + return cJobs[0].RunID, nil + } + + return 0, nil +} + +func checkBlockedConcurrentRun(ctx context.Context, repoID, runID int64) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { + concurrentRun, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID) + if err != nil { + return nil, nil, nil, fmt.Errorf("get run %d: %w", runID, err) + } + if concurrentRun.NeedApproval { + return nil, nil, nil, nil + } + + return checkJobsOfCurrentRunAttempt(ctx, concurrentRun) +} + +// checkRunConcurrency rechecks runs blocked by concurrency that may become unblocked after the current run releases a workflow-level or job-level concurrency group. +func checkRunConcurrency(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { checkedConcurrencyGroup := make(container.Set[string]) - // check run (workflow-level) concurrency - if run.ConcurrencyGroup != "" { - concurrentRun, err := findBlockedRunByConcurrency(ctx, run.RepoID, run.ConcurrencyGroup) + collect := func(concurrencyGroup string) error { + concurrentRunID, err := findBlockedRunIDByConcurrency(ctx, run.RepoID, concurrencyGroup) if err != nil { - return nil, nil, fmt.Errorf("find blocked run by concurrency: %w", err) + return fmt.Errorf("find blocked run by concurrency: %w", err) } - if concurrentRun != nil && !concurrentRun.NeedApproval { - js, ujs, err := checkJobsOfRun(ctx, concurrentRun) + if concurrentRunID > 0 { + js, ujs, cjs, err := checkBlockedConcurrentRun(ctx, run.RepoID, concurrentRunID) if err != nil { - return nil, nil, err + return err } jobs = append(jobs, js...) updatedJobs = append(updatedJobs, ujs...) + cancelledJobs = append(cancelledJobs, cjs...) + } + checkedConcurrencyGroup.Add(concurrencyGroup) + return nil + } + + // check run (workflow-level) concurrency + runConcurrencyGroup, _, err := run.GetEffectiveConcurrency(ctx) + if err != nil { + return nil, nil, nil, fmt.Errorf("GetEffectiveConcurrency: %w", err) + } + if runConcurrencyGroup != "" { + if err := collect(runConcurrencyGroup); err != nil { + return nil, nil, nil, err } - checkedConcurrencyGroup.Add(run.ConcurrencyGroup) } // check job concurrency - runJobs, err := db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) + runJobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) if err != nil { - return nil, nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) + return nil, nil, nil, fmt.Errorf("find run %d jobs: %w", run.ID, err) } for _, job := range runJobs { if !job.Status.IsDone() { continue } - if job.ConcurrencyGroup == "" && checkedConcurrencyGroup.Contains(job.ConcurrencyGroup) { + if job.ConcurrencyGroup == "" || checkedConcurrencyGroup.Contains(job.ConcurrencyGroup) { continue } - concurrentRun, err := findBlockedRunByConcurrency(ctx, job.RepoID, job.ConcurrencyGroup) - if err != nil { - return nil, nil, fmt.Errorf("find blocked run by concurrency: %w", err) + if err := collect(job.ConcurrencyGroup); err != nil { + return nil, nil, nil, err } - if concurrentRun != nil && !concurrentRun.NeedApproval { - js, ujs, err := checkJobsOfRun(ctx, concurrentRun) - if err != nil { - return nil, nil, err - } - jobs = append(jobs, js...) - updatedJobs = append(updatedJobs, ujs...) - } - checkedConcurrencyGroup.Add(job.ConcurrencyGroup) } - return jobs, updatedJobs, nil + return jobs, updatedJobs, cancelledJobs, nil } -func checkJobsOfRun(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs []*actions_model.ActionRunJob, err error) { - jobs, err = db.Find[actions_model.ActionRunJob](ctx, actions_model.FindRunJobOptions{RunID: run.ID}) +// checkJobsOfCurrentRunAttempt resolves blocked jobs of the run's latest attempt. +func checkJobsOfCurrentRunAttempt(ctx context.Context, run *actions_model.ActionRun) (jobs, updatedJobs, cancelledJobs []*actions_model.ActionRunJob, err error) { + jobs, err = actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, run.LatestAttemptID) if err != nil { - return nil, nil, err + return nil, nil, nil, err } vars, err := actions_model.GetVariablesOfRun(ctx, run) if err != nil { - return nil, nil, err + return nil, nil, nil, err } + resolver := newJobStatusResolver(jobs, vars) if err = db.WithTx(ctx, func(ctx context.Context) error { for _, job := range jobs { job.Run = run } - updates := newJobStatusResolver(jobs, vars).Resolve(ctx) + updates := resolver.Resolve(ctx) for _, job := range jobs { if status, ok := updates[job.ID]; ok { job.Status = status @@ -226,26 +253,18 @@ func checkJobsOfRun(ctx context.Context, run *actions_model.ActionRun) (jobs, up } return nil }); err != nil { - return nil, nil, err + return nil, nil, nil, err } - return jobs, updatedJobs, nil -} - -func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, job *actions_model.ActionRunJob) { - job.Run = nil - if err := job.LoadAttributes(ctx); err != nil { - log.Error("LoadAttributes: %v", err) - return - } - notify_service.WorkflowRunStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job.Run) + return jobs, updatedJobs, resolver.cancelledJobs, nil } type jobStatusResolver struct { - statuses map[int64]actions_model.Status - needs map[int64][]int64 - jobMap map[int64]*actions_model.ActionRunJob - vars map[string]string + statuses map[int64]actions_model.Status + needs map[int64][]int64 + jobMap map[int64]*actions_model.ActionRunJob + vars map[string]string + cancelledJobs []*actions_model.ActionRunJob } func newJobStatusResolver(jobs actions_model.ActionJobList, vars map[string]string) *jobStatusResolver { @@ -344,9 +363,12 @@ func (r *jobStatusResolver) resolve(ctx context.Context) map[int64]actions_model newStatus := util.Iif(shouldStartJob, actions_model.StatusWaiting, actions_model.StatusSkipped) if newStatus == actions_model.StatusWaiting { - newStatus, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob) + var cancelledJobs []*actions_model.ActionRunJob + newStatus, cancelledJobs, err = PrepareToStartJobWithConcurrency(ctx, actionRunJob) if err != nil { log.Error("ShouldBlockJobByConcurrency failed, this job will stay blocked: job: %d, err: %v", id, err) + } else { + r.cancelledJobs = append(r.cancelledJobs, cancelledJobs...) } } @@ -362,8 +384,16 @@ func updateConcurrencyEvaluationForJobWithNeeds(ctx context.Context, actionRunJo return nil // for testing purpose only, no repo, no evaluation } - err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, actionRunJob, vars, nil) - if err != nil { + // Legacy jobs (created before migration v331) have RunAttemptID=0 and no attempt record. + var attempt *actions_model.ActionRunAttempt + if actionRunJob.RunAttemptID > 0 { + var err error + attempt, err = actions_model.GetRunAttemptByRepoAndID(ctx, actionRunJob.RepoID, actionRunJob.RunAttemptID) + if err != nil { + return fmt.Errorf("GetRunAttemptByRepoAndID: %w", err) + } + } + if err := EvaluateJobConcurrencyFillModel(ctx, actionRunJob.Run, attempt, actionRunJob, vars, nil); err != nil { return fmt.Errorf("evaluate job concurrency: %w", err) } diff --git a/services/actions/job_emitter_test.go b/services/actions/job_emitter_test.go index a2152fb2700..11998e01b21 100644 --- a/services/actions/job_emitter_test.go +++ b/services/actions/job_emitter_test.go @@ -7,6 +7,8 @@ import ( "testing" actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" "github.com/stretchr/testify/assert" ) @@ -134,3 +136,95 @@ jobs: }) } } + +// Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck verifies that when a run's +// ConcurrencyGroup has already been checked at the run level, the same group is not +// re-checked for individual jobs. +func Test_checkRunConcurrency_NoDuplicateConcurrencyGroupCheck(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + ctx := t.Context() + + // Run A: the triggering run of attempt A + runA := &actions_model.ActionRun{ + RepoID: 4, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: "test.yml", + Index: 9901, + Ref: "refs/heads/main", + Status: actions_model.StatusRunning, + } + assert.NoError(t, db.Insert(ctx, runA)) + + // Attempt A: an attempt of run A with concurrency group "test-cg" + runAAttempt := &actions_model.ActionRunAttempt{ + RepoID: 4, + RunID: runA.ID, + Attempt: 1, + Status: actions_model.StatusRunning, + ConcurrencyGroup: "test-cg", + } + assert.NoError(t, db.Insert(ctx, runAAttempt)) + _, err := db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runAAttempt.ID, runA.ID) + assert.NoError(t, err) + + // A done job for run A with the same ConcurrencyGroup. + // This triggers the job-level concurrency check in checkRunConcurrency. + jobADone := &actions_model.ActionRunJob{ + RunID: runA.ID, + RunAttemptID: runAAttempt.ID, + AttemptJobID: 1, + RepoID: 4, + OwnerID: 1, + JobID: "job1", + Name: "job1", + Status: actions_model.StatusSuccess, + ConcurrencyGroup: "test-cg", + } + assert.NoError(t, db.Insert(ctx, jobADone)) + + // Run B: a run blocked by concurrency + runB := &actions_model.ActionRun{ + RepoID: 4, + OwnerID: 1, + TriggerUserID: 1, + WorkflowID: "test.yml", + Index: 9902, + Ref: "refs/heads/main", + Status: actions_model.StatusBlocked, + } + assert.NoError(t, db.Insert(ctx, runB)) + + // Attempt B: an blocked attempt of run B + runBAttempt := &actions_model.ActionRunAttempt{ + RepoID: 4, + RunID: runB.ID, + Attempt: 1, + Status: actions_model.StatusBlocked, + ConcurrencyGroup: "test-cg", + } + assert.NoError(t, db.Insert(ctx, runBAttempt)) + _, err = db.Exec(t.Context(), "UPDATE `action_run` SET latest_attempt_id = ? WHERE id = ?", runBAttempt.ID, runB.ID) + assert.NoError(t, err) + + // A blocked job belonging to run B (no job-level concurrency group). + jobBBlocked := &actions_model.ActionRunJob{ + RunID: runB.ID, + RunAttemptID: runBAttempt.ID, + AttemptJobID: 1, + RepoID: 4, + OwnerID: 1, + JobID: "job1", + Name: "job1", + Status: actions_model.StatusBlocked, + } + assert.NoError(t, db.Insert(ctx, jobBBlocked)) + + runA, _, _ = db.GetByID[actions_model.ActionRun](t.Context(), runA.ID) + jobs, _, _, err := checkRunConcurrency(ctx, runA) + assert.NoError(t, err) + + if assert.Len(t, jobs, 1) { + assert.Equal(t, jobBBlocked.ID, jobs[0].ID) + } +} diff --git a/services/actions/notifier.go b/services/actions/notifier.go index 19d6be94207..c3b2003b3cd 100644 --- a/services/actions/notifier.go +++ b/services/actions/notifier.go @@ -5,6 +5,7 @@ package actions import ( "context" + "errors" actions_model "code.gitea.io/gitea/models/actions" issues_model "code.gitea.io/gitea/models/issues" @@ -20,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/convert" notify_service "code.gitea.io/gitea/services/notify" @@ -805,12 +807,15 @@ func (n *actionsNotifier) WorkflowRunStatusUpdate(ctx context.Context, repo *rep } defer gitRepo.Close() - convertedWorkflow, err := convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + convertedWorkflow, err := convert.GetActionWorkflowByRef(ctx, gitRepo, repo, run.WorkflowID, git.RefName(run.Ref)) + if err != nil && errors.Is(err, util.ErrNotExist) { + convertedWorkflow, err = convert.GetActionWorkflow(ctx, gitRepo, repo, run.WorkflowID) + } if err != nil { log.Error("GetActionWorkflow: %v", err) return } - convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run) + convertedRun, err := convert.ToActionWorkflowRun(ctx, repo, run, nil) if err != nil { log.Error("ToActionWorkflowRun: %v", err) return diff --git a/services/actions/notify.go b/services/actions/notify.go new file mode 100644 index 00000000000..e8b05c9fecd --- /dev/null +++ b/services/actions/notify.go @@ -0,0 +1,144 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "context" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/log" + notify_service "code.gitea.io/gitea/services/notify" +) + +// NotifyWorkflowJobsAndRunsStatusUpdate notifies status changes for a batch of jobs and the runs they affect. +// Use it when a workflow operation updates multiple jobs and runs. +func NotifyWorkflowJobsAndRunsStatusUpdate(ctx context.Context, jobs []*actions_model.ActionRunJob) { + if len(jobs) == 0 { + return + } + + // The input jobs may belong to different runs, so track each affected run. + runs := make(map[int64]*actions_model.ActionRun, len(jobs)) + jobsByRunID := make(map[int64][]*actions_model.ActionRunJob) + + for _, job := range jobs { + if err := job.LoadAttributes(ctx); err != nil { + log.Error("Failed to load job attributes: %v", err) + continue + } + CreateCommitStatusForRunJobs(ctx, job.Run, job) + + if _, ok := runs[job.RunID]; !ok { + runs[job.RunID] = job.Run + } + if _, ok := jobsByRunID[job.RunID]; !ok { + jobsByRunID[job.RunID] = make([]*actions_model.ActionRunJob, 0) + } + jobsByRunID[job.RunID] = append(jobsByRunID[job.RunID], job) + } + + for _, run := range runs { + NotifyWorkflowRunStatusUpdate(ctx, run) + } + + for _, jobs := range jobsByRunID { + NotifyWorkflowJobsStatusUpdate(ctx, jobs...) + } +} + +// NotifyWorkflowRunStatusUpdateWithReload reloads the run before notifying its status update. +// Use it when only repo/run IDs are available or when the in-memory run may be stale after job updates. +func NotifyWorkflowRunStatusUpdateWithReload(ctx context.Context, repoID, runID int64) { + run, err := actions_model.GetRunByRepoAndID(ctx, repoID, runID) + if err != nil { + log.Error("GetRunByRepoAndID: %v", err) + return + } + NotifyWorkflowRunStatusUpdate(ctx, run) +} + +// NotifyWorkflowRunStatusUpdate notifies a run status update using the latest attempt trigger user when available. +// Use it for run-level notifications when the caller already has the run model loaded. +func NotifyWorkflowRunStatusUpdate(ctx context.Context, run *actions_model.ActionRun) { + if err := run.LoadAttributes(ctx); err != nil { + log.Error("run.LoadAttributes: %v", err) + return + } + triggerUser := run.TriggerUser + if run.LatestAttemptID > 0 { + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, run.RepoID, run.LatestAttemptID) + if err != nil { + log.Error("GetRunAttemptByRepoAndID: %v", err) + return + } + if err := attempt.LoadAttributes(ctx); err != nil { + log.Error("attempt.LoadAttributes: %v", err) + return + } + triggerUser = attempt.TriggerUser + } + notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, triggerUser, run) +} + +// NotifyWorkflowJobsStatusUpdate notifies status updates for jobs without task. +// Use it for batch or single-job notifications after state changes. +func NotifyWorkflowJobsStatusUpdate(ctx context.Context, jobs ...*actions_model.ActionRunJob) { + jobsByAttempt := make(map[int64][]*actions_model.ActionRunJob) + for _, job := range jobs { + if _, ok := jobsByAttempt[job.RunAttemptID]; !ok { + jobsByAttempt[job.RunAttemptID] = make([]*actions_model.ActionRunJob, 0) + } + jobsByAttempt[job.RunAttemptID] = append(jobsByAttempt[job.RunAttemptID], job) + } + + for attemptID, js := range jobsByAttempt { + if attemptID == 0 { + for _, job := range js { + if err := job.LoadAttributes(ctx); err != nil { + log.Error("job.LoadAttributes: %v", err) + continue + } + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) + } + continue + } + + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, js[0].RepoID, attemptID) + if err != nil { + log.Error("GetRunAttemptByRepoAndID: %v", err) + continue + } + if err := attempt.LoadAttributes(ctx); err != nil { + log.Error("attempt.LoadAttributes: %v", err) + continue + } + for _, job := range js { + notify_service.WorkflowJobStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, nil) + } + } +} + +// NotifyWorkflowJobStatusUpdateWithTask notifies a single job status update when a concrete task is available. +// Use it for runner/task lifecycle callbacks so the notification includes the originating task context. +func NotifyWorkflowJobStatusUpdateWithTask(ctx context.Context, job *actions_model.ActionRunJob, task *actions_model.ActionTask) { + if job.RunAttemptID == 0 { + if err := job.LoadAttributes(ctx); err != nil { + log.Error("job.LoadAttributes: %v", err) + return + } + notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, task) + return + } + + attempt, err := actions_model.GetRunAttemptByRepoAndID(ctx, job.RepoID, job.RunAttemptID) + if err != nil { + log.Error("GetRunAttemptByRepoAndID: %v", err) + return + } + if err := attempt.LoadAttributes(ctx); err != nil { + log.Error("attempt.LoadAttributes: %v", err) + return + } + notify_service.WorkflowJobStatusUpdate(ctx, attempt.Run.Repo, attempt.TriggerUser, job, task) +} diff --git a/services/actions/rerun.go b/services/actions/rerun.go index 1596d9bfc5a..f253181a8dd 100644 --- a/services/actions/rerun.go +++ b/services/actions/rerun.go @@ -6,57 +6,312 @@ package actions import ( "context" "fmt" + "slices" actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" - notify_service "code.gitea.io/gitea/services/notify" "github.com/nektos/act/pkg/model" "go.yaml.in/yaml/v4" - "xorm.io/builder" ) -// GetFailedRerunJobs returns all failed jobs and their downstream dependent jobs that need to be rerun -func GetFailedRerunJobs(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { - rerunJobIDSet := make(container.Set[int64]) +// GetFailedJobsForRerun returns the failed or cancelled jobs in a run. +func GetFailedJobsForRerun(allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { var jobsToRerun []*actions_model.ActionRunJob for _, job := range allJobs { if job.Status == actions_model.StatusFailure || job.Status == actions_model.StatusCancelled { - for _, j := range GetAllRerunJobs(job, allJobs) { - if !rerunJobIDSet.Contains(j.ID) { - rerunJobIDSet.Add(j.ID) - jobsToRerun = append(jobsToRerun, j) - } - } + jobsToRerun = append(jobsToRerun, job) } } return jobsToRerun } -// GetAllRerunJobs returns the target job and all jobs that transitively depend on it. -// Downstream jobs are included regardless of their current status. -func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.ActionRunJob) []*actions_model.ActionRunJob { - rerunJobs := []*actions_model.ActionRunJob{job} - rerunJobsIDSet := make(container.Set[string]) - rerunJobsIDSet.Add(job.JobID) +// RerunWorkflowRunJobs reruns the given jobs of a workflow run. +// An empty jobsToRerun means rerunning the whole run. Otherwise jobsToRerun contains only the user-requested target jobs; +// downstream dependent jobs are expanded internally while building the rerun plan. +// +// The three stages below (legacy backfill, plan build, plan exec) deliberately run in separate DB transactions +// rather than one big outer transaction: +// - execRerunPlan performs slow work (loading variables, YAML unmarshal, concurrency expression evaluation) +// before opening its own transaction, so the tx stays focused on inserts/updates. +// - The legacy backfill is idempotent-friendly: if it succeeds but a later stage fails, a subsequent rerun +// will observe run.LatestAttemptID != 0 and skip the backfill, continuing naturally. No data corruption +// or stuck state results from partial progress. +// +// Fast validations that can catch failures early (workflow disabled, run not done, etc.) are therefore +// pushed into validateRerun so we rarely enter createOriginalAttemptForLegacyRun only to fail afterwards. +func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) (*actions_model.ActionRunAttempt, error) { + if err := validateRerun(ctx, run, repo, triggerUser, jobsToRerun); err != nil { + return nil, err + } + + if run.LatestAttemptID == 0 { + if err := createOriginalAttemptForLegacyRun(ctx, run); err != nil { + return nil, fmt.Errorf("create attempt for legacy run: %w", err) + } + } + + plan, err := buildRerunPlan(ctx, run, triggerUser, jobsToRerun) + if err != nil { + return nil, err + } + return execRerunPlan(ctx, plan) +} + +func validateRerun(ctx context.Context, run *actions_model.ActionRun, repo *repo_model.Repository, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) error { + if !run.Status.IsDone() { + return util.NewInvalidArgumentErrorf("this workflow run is not done") + } + if repo == nil { + return util.NewInvalidArgumentErrorf("repo is required") + } + if run.RepoID != repo.ID { + return util.NewInvalidArgumentErrorf("run %d does not belong to repo %d", run.ID, repo.ID) + } + for _, job := range jobsToRerun { + if job.RunID != run.ID { + return util.NewInvalidArgumentErrorf("job %d does not belong to workflow run %d", job.ID, run.ID) + } + } + if triggerUser == nil { + return util.NewInvalidArgumentErrorf("trigger user is required") + } + cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) + cfg := cfgUnit.ActionsConfig() + if cfg.IsWorkflowDisabled(run.WorkflowID) { + return util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) + } + + // Legacy runs (LatestAttemptID == 0) conceptually have only attempt 1, so they can never be at the cap. + // For non-legacy runs, look up the latest attempt and reject when its number is already at the configured cap. + if run.LatestAttemptID > 0 { + latestAttempt, has, err := run.GetLatestAttempt(ctx) + if err != nil { + return fmt.Errorf("GetLatestAttempt: %w", err) + } + if has && latestAttempt.Attempt >= setting.Actions.MaxRerunAttempts { + return util.NewInvalidArgumentErrorf("workflow run has reached the maximum of %d attempts", setting.Actions.MaxRerunAttempts) + } + } + + return nil +} + +// rerunPlan is a read-only snapshot of the inputs needed to execute a rerun. +// It holds no to-be-persisted entities and no intermediate evaluation results; +// execRerunPlan constructs and evaluates the new ActionRunAttempt itself. +type rerunPlan struct { + run *actions_model.ActionRun + templateAttempt *actions_model.ActionRunAttempt + templateJobs actions_model.ActionJobList + rerunJobIDs container.Set[string] + triggerUser *user_model.User +} + +// buildRerunPlan constructs a rerunPlan for the given workflow run without writing to the database. +// jobsToRerun contains only the user-requested target jobs. An empty jobsToRerun means the entire run should be rerun. +// It loads the latest attempt as a template and expands jobsToRerun to include all transitive downstream dependents. +// The construction of new-attempt and concurrency evaluation are deferred to execRerunPlan so that the plan remains a pure input snapshot. +func buildRerunPlan(ctx context.Context, run *actions_model.ActionRun, triggerUser *user_model.User, jobsToRerun []*actions_model.ActionRunJob) (*rerunPlan, error) { + if err := run.LoadAttributes(ctx); err != nil { + return nil, err + } + + templateAttempt, hasTemplateAttempt, err := run.GetLatestAttempt(ctx) + if err != nil { + return nil, err + } + if !hasTemplateAttempt { + return nil, util.NewNotExistErrorf("latest attempt not found") + } + + templateJobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, templateAttempt.ID) + if err != nil { + return nil, fmt.Errorf("load template jobs: %w", err) + } + if len(templateJobs) == 0 { + return nil, util.NewNotExistErrorf("no template jobs") + } + + plan := &rerunPlan{ + run: run, + templateAttempt: templateAttempt, + templateJobs: templateJobs, + triggerUser: triggerUser, + } + + if err := plan.expandRerunJobIDs(jobsToRerun); err != nil { + return nil, err + } + + return plan, nil +} + +// execRerunPlan executes the rerun plan built by buildRerunPlan. +// It loads run variables, constructs the new ActionRunAttempt and evaluates run-level concurrency (all outside the transaction to keep the tx short). +// Inside a single database transaction it then inserts the new attempt, clones all template jobs, evaluates job-level concurrency for rerun jobs, +// and updates the run's latest_attempt_id. +// Jobs not in the rerun set are cloned as pass-through: their status is preserved and SourceTaskID points to the original task so the UI can still display their results. +// The attempt's final status is derived only from the rerun jobs, not the pass-through jobs. +// Notifications and commit statuses are sent after the transaction commits. +func execRerunPlan(ctx context.Context, plan *rerunPlan) (*actions_model.ActionRunAttempt, error) { + vars, err := actions_model.GetVariablesOfRun(ctx, plan.run) + if err != nil { + return nil, fmt.Errorf("get run %d variables: %w", plan.run.ID, err) + } + + newAttempt := &actions_model.ActionRunAttempt{ + RepoID: plan.run.RepoID, + RunID: plan.run.ID, + Attempt: plan.templateAttempt.Attempt + 1, + TriggerUserID: plan.triggerUser.ID, + Status: actions_model.StatusWaiting, + } + + if plan.run.RawConcurrency != "" { + var rawConcurrency model.RawConcurrency + if err := yaml.Unmarshal([]byte(plan.run.RawConcurrency), &rawConcurrency); err != nil { + return nil, fmt.Errorf("unmarshal raw concurrency: %w", err) + } + if err := EvaluateRunConcurrencyFillModel(ctx, plan.run, newAttempt, &rawConcurrency, vars, nil); err != nil { + return nil, err + } + } + + var newJobs, newJobsToRerun actions_model.ActionJobList + var cancelledConcurrencyJobs []*actions_model.ActionRunJob + + err = db.WithTx(ctx, func(ctx context.Context) error { + newAttemptStatus, jobsToCancel, err := PrepareToStartRunWithConcurrency(ctx, newAttempt) + if err != nil { + return err + } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) + newAttempt.Status = newAttemptStatus + shouldBlock := newAttemptStatus == actions_model.StatusBlocked + + if err := db.Insert(ctx, newAttempt); err != nil { + if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, plan.run.ID, newAttempt.Attempt); getErr == nil { + return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", newAttempt.Attempt, plan.run.ID) + } + return err + } + + plan.run.LatestAttemptID = newAttempt.ID + if err := actions_model.UpdateRun(ctx, plan.run, "latest_attempt_id"); err != nil { + return err + } + + hasWaitingJobs := false + newJobs = make(actions_model.ActionJobList, 0, len(plan.templateJobs)) + newJobsToRerun = make(actions_model.ActionJobList, 0, len(plan.rerunJobIDs)) + for _, templateJob := range plan.templateJobs { + newJob := cloneRunJobForAttempt(templateJob, newAttempt) + if plan.rerunJobIDs.Contains(templateJob.JobID) { + shouldBlockJob := shouldBlock || plan.hasRerunDependency(templateJob) + + newJob.Status = util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting) + newJob.TaskID = 0 + newJob.SourceTaskID = 0 + newJob.Started = 0 + newJob.Stopped = 0 + newJob.ConcurrencyGroup = "" + newJob.ConcurrencyCancel = false + newJob.IsConcurrencyEvaluated = false + + if newJob.RawConcurrency != "" && !shouldBlockJob { + if err := EvaluateJobConcurrencyFillModel(ctx, plan.run, newAttempt, newJob, vars, nil); err != nil { + return fmt.Errorf("evaluate job concurrency: %w", err) + } + newJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, newJob) + if err != nil { + return fmt.Errorf("prepare to start job with concurrency: %w", err) + } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) + } + + newJobsToRerun = append(newJobsToRerun, newJob) + } else { + newJob.TaskID = 0 + newJob.SourceTaskID = templateJob.EffectiveTaskID() + newJob.Started = templateJob.Started + newJob.Stopped = templateJob.Stopped + } + + if err := db.Insert(ctx, newJob); err != nil { + return err + } + hasWaitingJobs = hasWaitingJobs || newJob.Status == actions_model.StatusWaiting + newJobs = append(newJobs, newJob) + } + + newAttempt.Status = actions_model.AggregateJobStatus(newJobsToRerun) + if err := actions_model.UpdateRunAttempt(ctx, newAttempt, "status"); err != nil { + return err + } + + if hasWaitingJobs { + if err := actions_model.IncreaseTaskVersion(ctx, plan.run.OwnerID, plan.run.RepoID); err != nil { + return err + } + } + + return nil + }) + if err != nil { + return nil, err + } + + if err := plan.run.LoadAttributes(ctx); err != nil { + return nil, err + } + + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs) + EmitJobsIfReadyByJobs(cancelledConcurrencyJobs) + + CreateCommitStatusForRunJobs(ctx, plan.run, newJobs...) + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, newJobsToRerun) + + return newAttempt, nil +} + +func (p *rerunPlan) expandRerunJobIDs(jobsToRerun []*actions_model.ActionRunJob) error { + templateJobIDs := make(container.Set[string]) + for _, job := range p.templateJobs { + templateJobIDs.Add(job.JobID) + } + + if len(jobsToRerun) == 0 { + p.rerunJobIDs = templateJobIDs + return nil + } + + rerunJobIDs := make(container.Set[string]) + for _, job := range jobsToRerun { + if !templateJobIDs.Contains(job.JobID) { + return util.NewInvalidArgumentErrorf("job %q does not exist in the latest attempt", job.JobID) + } + rerunJobIDs.Add(job.JobID) + } for { found := false - for _, j := range allJobs { - if rerunJobsIDSet.Contains(j.JobID) { + for _, job := range p.templateJobs { + if rerunJobIDs.Contains(job.JobID) { continue } - for _, need := range j.Needs { - if rerunJobsIDSet.Contains(need) { + for _, need := range job.Needs { + if rerunJobIDs.Contains(need) { found = true - rerunJobs = append(rerunJobs, j) - rerunJobsIDSet.Add(j.JobID) + rerunJobIDs.Add(job.JobID) break } } @@ -66,152 +321,100 @@ func GetAllRerunJobs(job *actions_model.ActionRunJob, allJobs []*actions_model.A } } - return rerunJobs + p.rerunJobIDs = rerunJobIDs + return nil } -// prepareRunRerun validates the run, resets its state, handles concurrency, persists the -// updated run, and fires a status-update notification. -// It returns isRunBlocked (true when the run itself is held by a concurrency group). -func prepareRunRerun(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobs []*actions_model.ActionRunJob) (isRunBlocked bool, err error) { - if !run.Status.IsDone() { - return false, util.NewInvalidArgumentErrorf("this workflow run is not done") - } - - cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions) - - // Rerun is not allowed when workflow is disabled. - cfg := cfgUnit.ActionsConfig() - if cfg.IsWorkflowDisabled(run.WorkflowID) { - return false, util.NewInvalidArgumentErrorf("workflow %s is disabled", run.WorkflowID) - } - - // Reset run's timestamps and status. - run.PreviousDuration = run.Duration() - run.Started = 0 - run.Stopped = 0 - run.Status = actions_model.StatusWaiting - - vars, err := actions_model.GetVariablesOfRun(ctx, run) - if err != nil { - return false, fmt.Errorf("get run %d variables: %w", run.ID, err) - } - - if run.RawConcurrency != "" { - var rawConcurrency model.RawConcurrency - if err := yaml.Unmarshal([]byte(run.RawConcurrency), &rawConcurrency); err != nil { - return false, fmt.Errorf("unmarshal raw concurrency: %w", err) +func (p *rerunPlan) hasRerunDependency(job *actions_model.ActionRunJob) bool { + for _, need := range job.Needs { + if p.rerunJobIDs.Contains(need) { + return true } + } + return false +} - if err := EvaluateRunConcurrencyFillModel(ctx, run, &rawConcurrency, vars, nil); err != nil { - return false, err - } +func cloneRunJobForAttempt(templateJob *actions_model.ActionRunJob, attempt *actions_model.ActionRunAttempt) *actions_model.ActionRunJob { + return &actions_model.ActionRunJob{ + RunID: templateJob.RunID, + RunAttemptID: attempt.ID, + RepoID: templateJob.RepoID, + OwnerID: templateJob.OwnerID, + CommitSHA: templateJob.CommitSHA, + IsForkPullRequest: templateJob.IsForkPullRequest, + Name: templateJob.Name, + Attempt: attempt.Attempt, + WorkflowPayload: slices.Clone(templateJob.WorkflowPayload), + JobID: templateJob.JobID, + AttemptJobID: templateJob.AttemptJobID, + Needs: slices.Clone(templateJob.Needs), + RunsOn: slices.Clone(templateJob.RunsOn), + Status: templateJob.Status, + RawConcurrency: templateJob.RawConcurrency, + IsConcurrencyEvaluated: templateJob.IsConcurrencyEvaluated, + ConcurrencyGroup: templateJob.ConcurrencyGroup, + ConcurrencyCancel: templateJob.ConcurrencyCancel, + TokenPermissions: templateJob.TokenPermissions, + } +} - run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) +// createOriginalAttemptForLegacyRun creates a real attempt=1 for a legacy run and updates the existing legacy jobs and artifacts in place +// so the original execution becomes attempt-aware before the rerun plan is built and all subsequent logic can use real attempts. +// Tasks are not modified: they reference jobs by JobID, so updating jobs implicitly carries the new attempt linkage. +func createOriginalAttemptForLegacyRun(ctx context.Context, run *actions_model.ActionRun) error { + return db.WithTx(ctx, func(ctx context.Context) error { + jobs, err := actions_model.GetRunJobsByRunAndAttemptID(ctx, run.ID, 0) if err != nil { - return false, err + return fmt.Errorf("load legacy run jobs: %w", err) + } + if len(jobs) == 0 { + return fmt.Errorf("run %d has no jobs", run.ID) } - } - if err := actions_model.UpdateRun(ctx, run, "started", "stopped", "previous_duration", "status", "concurrency_group", "concurrency_cancel"); err != nil { - return false, err - } + originalAttempt := &actions_model.ActionRunAttempt{ + RepoID: run.RepoID, + RunID: run.ID, + Attempt: 1, + TriggerUserID: run.TriggerUserID, - if err := run.LoadAttributes(ctx); err != nil { - return false, err - } + // Legacy concurrency fields on ActionRun are intentionally NOT backfilled onto this original attempt. + // They only matter while a run is actively being scheduled, and backfilling them for completed legacy runs + // would add migration/runtime cost without changing any future concurrency behavior. - for _, job := range jobs { - job.Run = run - } + Status: run.Status, + Created: run.Created, + Started: run.Started, + Stopped: run.Stopped, + } - notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) + // Use NoAutoTime so xorm does not overwrite Created with the current time on insert. + if _, err := db.GetEngine(ctx).NoAutoTime().Insert(originalAttempt); err != nil { + if _, getErr := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, originalAttempt.Attempt); getErr == nil { + return util.NewAlreadyExistErrorf("workflow run attempt %d for run %d already exists", originalAttempt.Attempt, run.ID) + } + return err + } - return run.Status == actions_model.StatusBlocked, nil -} - -// RerunWorkflowRunJobs reruns the given jobs of a workflow run. -// jobsToRerun must include all jobs to be rerun (the target job and its transitively dependent jobs). -// A job is blocked (waiting for dependencies) if the run itself is blocked or if any of its -// needs are also being rerun. -func RerunWorkflowRunJobs(ctx context.Context, repo *repo_model.Repository, run *actions_model.ActionRun, jobsToRerun []*actions_model.ActionRunJob) error { - if len(jobsToRerun) == 0 { - return nil - } - - isRunBlocked, err := prepareRunRerun(ctx, repo, run, jobsToRerun) - if err != nil { - return err - } - - rerunJobIDs := make(container.Set[string]) - for _, j := range jobsToRerun { - rerunJobIDs.Add(j.JobID) - } - - for _, job := range jobsToRerun { - shouldBlockJob := isRunBlocked - if !shouldBlockJob { - for _, need := range job.Needs { - if rerunJobIDs.Contains(need) { - shouldBlockJob = true - break - } + // backfill attempt related fields for jobs + for i, job := range jobs { + job.RunAttemptID = originalAttempt.ID + job.Attempt = originalAttempt.Attempt + job.AttemptJobID = int64(i + 1) + if _, err := db.GetEngine(ctx).ID(job.ID).Cols("run_attempt_id", "attempt", "attempt_job_id").Update(job); err != nil { + return fmt.Errorf("backfill legacy run jobs: %w", err) } } - if err := rerunWorkflowJob(ctx, job, shouldBlockJob); err != nil { - return err - } - } - return nil -} - -func rerunWorkflowJob(ctx context.Context, job *actions_model.ActionRunJob, shouldBlock bool) error { - status := job.Status - if !status.IsDone() { - return nil - } - - job.TaskID = 0 - job.Status = util.Iif(shouldBlock, actions_model.StatusBlocked, actions_model.StatusWaiting) - job.Started = 0 - job.Stopped = 0 - job.ConcurrencyGroup = "" - job.ConcurrencyCancel = false - job.IsConcurrencyEvaluated = false - - if err := job.LoadRun(ctx); err != nil { - return err - } - if err := job.Run.LoadAttributes(ctx); err != nil { - return err - } - - vars, err := actions_model.GetVariablesOfRun(ctx, job.Run) - if err != nil { - return fmt.Errorf("get run %d variables: %w", job.Run.ID, err) - } - - if job.RawConcurrency != "" && !shouldBlock { - if err := EvaluateJobConcurrencyFillModel(ctx, job.Run, job, vars, nil); err != nil { - return fmt.Errorf("evaluate job concurrency: %w", err) - } - - job.Status, err = PrepareToStartJobWithConcurrency(ctx, job) - if err != nil { - return err - } - } - - if err := db.WithTx(ctx, func(ctx context.Context) error { - updateCols := []string{"task_id", "status", "started", "stopped", "concurrency_group", "concurrency_cancel", "is_concurrency_evaluated"} - _, err := actions_model.UpdateRunJob(ctx, job, builder.Eq{"status": status}, updateCols...) - return err - }); err != nil { - return err - } - - CreateCommitStatusForRunJobs(ctx, job.Run, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, nil) - return nil + // backfill "run_attempt_id" field for artifacts + if _, err := db.GetEngine(ctx). + Where("run_id=? AND run_attempt_id=0", run.ID). + Cols("run_attempt_id"). + Update(&actions_model.ActionArtifact{RunAttemptID: originalAttempt.ID}); err != nil { + return fmt.Errorf("backfill legacy artifacts: %w", err) + } + + // update "latest_attempt_id" for the run + run.LatestAttemptID = originalAttempt.ID + return actions_model.UpdateRun(ctx, run, "latest_attempt_id") + }) } diff --git a/services/actions/rerun_test.go b/services/actions/rerun_test.go index 3b4dc5483f4..30772980619 100644 --- a/services/actions/rerun_test.go +++ b/services/actions/rerun_test.go @@ -4,54 +4,17 @@ package actions import ( - "context" "testing" actions_model "code.gitea.io/gitea/models/actions" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestGetAllRerunJobs(t *testing.T) { - job1 := &actions_model.ActionRunJob{JobID: "job1"} - job2 := &actions_model.ActionRunJob{JobID: "job2", Needs: []string{"job1"}} - job3 := &actions_model.ActionRunJob{JobID: "job3", Needs: []string{"job2"}} - job4 := &actions_model.ActionRunJob{JobID: "job4", Needs: []string{"job2", "job3"}} - - jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} - - testCases := []struct { - job *actions_model.ActionRunJob - rerunJobs []*actions_model.ActionRunJob - }{ - { - job1, - []*actions_model.ActionRunJob{job1, job2, job3, job4}, - }, - { - job2, - []*actions_model.ActionRunJob{job2, job3, job4}, - }, - { - job3, - []*actions_model.ActionRunJob{job3, job4}, - }, - { - job4, - []*actions_model.ActionRunJob{job4}, - }, - } - - for _, tc := range testCases { - rerunJobs := GetAllRerunJobs(tc.job, jobs) - assert.ElementsMatch(t, tc.rerunJobs, rerunJobs) - } -} - -func TestGetFailedRerunJobs(t *testing.T) { - // IDs must be non-zero to distinguish jobs in the dedup set. +func TestGetFailedJobsForRerun(t *testing.T) { makeJob := func(id int64, jobID string, status actions_model.Status, needs ...string) *actions_model.ActionRunJob { return &actions_model.ActionRunJob{ID: id, JobID: jobID, Status: status, Needs: needs} } @@ -61,7 +24,7 @@ func TestGetFailedRerunJobs(t *testing.T) { makeJob(1, "job1", actions_model.StatusSuccess), makeJob(2, "job2", actions_model.StatusSkipped, "job1"), } - assert.Empty(t, GetFailedRerunJobs(jobs)) + assert.Empty(t, GetFailedJobsForRerun(jobs)) }) t.Run("single failed job with no dependents", func(t *testing.T) { @@ -69,56 +32,50 @@ func TestGetFailedRerunJobs(t *testing.T) { job2 := makeJob(2, "job2", actions_model.StatusSuccess) jobs := []*actions_model.ActionRunJob{job1, job2} - result := GetFailedRerunJobs(jobs) + result := GetFailedJobsForRerun(jobs) assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) - t.Run("failed job pulls in downstream dependents", func(t *testing.T) { - // job1 failed; job2 depends on job1 (skipped); job3 depends on job2 (skipped) + t.Run("failed job does not pull in downstream dependents", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusSkipped, "job1") job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job2") job4 := makeJob(4, "job4", actions_model.StatusSuccess) // unrelated, must not appear jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} - result := GetFailedRerunJobs(jobs) - assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result) + result := GetFailedJobsForRerun(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) - t.Run("multiple independent failed jobs each pull in their own dependents", func(t *testing.T) { - // job1 failed -> job3 depends on job1 - // job2 failed -> job4 depends on job2 + t.Run("multiple failed jobs are returned directly", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusFailure) job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1") job4 := makeJob(4, "job4", actions_model.StatusSkipped, "job2") jobs := []*actions_model.ActionRunJob{job1, job2, job3, job4} - result := GetFailedRerunJobs(jobs) - assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3, job4}, result) + result := GetFailedJobsForRerun(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) }) - t.Run("shared downstream dependent is not duplicated", func(t *testing.T) { - // job1 and job2 both failed; job3 depends on both + t.Run("shared downstream dependent is not included", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusFailure) job3 := makeJob(3, "job3", actions_model.StatusSkipped, "job1", "job2") jobs := []*actions_model.ActionRunJob{job1, job2, job3} - result := GetFailedRerunJobs(jobs) - assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2, job3}, result) - assert.Len(t, result, 3) // job3 must appear exactly once + result := GetFailedJobsForRerun(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) + assert.Len(t, result, 2) }) - t.Run("successful downstream job of a failed job is still included", func(t *testing.T) { - // job1 failed; job2 succeeded but depends on job1 — downstream is always rerun - // regardless of its own status (GetAllRerunJobs includes all transitive dependents) + t.Run("successful downstream job of a failed job is not included", func(t *testing.T) { job1 := makeJob(1, "job1", actions_model.StatusFailure) job2 := makeJob(2, "job2", actions_model.StatusSuccess, "job1") jobs := []*actions_model.ActionRunJob{job1, job2} - result := GetFailedRerunJobs(jobs) - assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1, job2}, result) + result := GetFailedJobsForRerun(jobs) + assert.ElementsMatch(t, []*actions_model.ActionRunJob{job1}, result) }) } @@ -129,7 +86,7 @@ func TestRerunValidation(t *testing.T) { jobs := []*actions_model.ActionRunJob{ {ID: 1, JobID: "job1"}, } - err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, jobs) + _, err := RerunWorkflowRunJobs(t.Context(), nil, runningRun, &user_model.User{ID: 1}, jobs) require.Error(t, err) assert.ErrorIs(t, err, util.ErrInvalidArgument) }) @@ -138,7 +95,7 @@ func TestRerunValidation(t *testing.T) { jobs := []*actions_model.ActionRunJob{ {ID: 1, JobID: "job1", Status: actions_model.StatusFailure}, } - err := RerunWorkflowRunJobs(context.Background(), nil, runningRun, GetFailedRerunJobs(jobs)) + _, err := RerunWorkflowRunJobs(t.Context(), nil, runningRun, &user_model.User{ID: 1}, GetFailedJobsForRerun(jobs)) require.Error(t, err) assert.ErrorIs(t, err, util.ErrInvalidArgument) }) diff --git a/services/actions/run.go b/services/actions/run.go index e9fcdcaf43d..162e3678aee 100644 --- a/services/actions/run.go +++ b/services/actions/run.go @@ -11,8 +11,8 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/actions/jobparser" "code.gitea.io/gitea/modules/util" - notify_service "code.gitea.io/gitea/services/notify" + act_model "github.com/nektos/act/pkg/model" "go.yaml.in/yaml/v4" ) @@ -34,25 +34,7 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model return fmt.Errorf("ReadWorkflowRawConcurrency: %w", err) } - if wfRawConcurrency != nil { - err = EvaluateRunConcurrencyFillModel(ctx, run, wfRawConcurrency, vars, inputsWithDefaults) - if err != nil { - return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err) - } - } - - giteaCtx := GenerateGiteaContext(run, nil) - - jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputsWithDefaults)) - if err != nil { - return fmt.Errorf("parse workflow: %w", err) - } - - if len(jobs) > 0 && jobs[0].RunName != "" { - run.Title = jobs[0].RunName - } - - if err = InsertRun(ctx, run, jobs, vars, inputsWithDefaults); err != nil { + if err = InsertRun(ctx, run, content, vars, inputsWithDefaults, wfRawConcurrency); err != nil { return fmt.Errorf("InsertRun: %w", err) } @@ -64,47 +46,89 @@ func PrepareRunAndInsert(ctx context.Context, content []byte, run *actions_model CreateCommitStatusForRunJobs(ctx, run, allJobs...) - notify_service.WorkflowRunStatusUpdate(ctx, run.Repo, run.TriggerUser, run) - for _, job := range allJobs { - notify_service.WorkflowJobStatusUpdate(ctx, run.Repo, run.TriggerUser, job, nil) - } + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, allJobs) return nil } // InsertRun inserts a run // The title will be cut off at 255 characters if it's longer than 255 characters. -func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobparser.SingleWorkflow, vars map[string]string, inputs map[string]any) error { - return db.WithTx(ctx, func(ctx context.Context) error { +func InsertRun(ctx context.Context, run *actions_model.ActionRun, content []byte, vars map[string]string, inputs map[string]any, wfRawConcurrency *act_model.RawConcurrency) error { + var cancelledConcurrencyJobs []*actions_model.ActionRunJob + if err := db.WithTx(ctx, func(ctx context.Context) error { index, err := db.GetNextResourceIndex(ctx, "action_run_index", run.RepoID) if err != nil { return err } run.Index = index run.Title = util.EllipsisDisplayString(run.Title, 255) + run.Status = actions_model.StatusWaiting - // check run (workflow-level) concurrency - run.Status, err = PrepareToStartRunWithConcurrency(ctx, run) - if err != nil { - return err + if wfRawConcurrency != nil { + rawConcurrency, err := yaml.Marshal(wfRawConcurrency) + if err != nil { + return fmt.Errorf("marshal raw concurrency: %w", err) + } + run.RawConcurrency = string(rawConcurrency) } + // Insert before parsing jobs or evaluating workflow-level concurrency + // so that run.ID is populated. Expressions referencing github.run_id — + // in run-name, job names, runs-on, or a workflow-level concurrency + // group like `${{ github.head_ref || github.run_id }}` — would otherwise + // interpolate to an empty string. if err := db.Insert(ctx, run); err != nil { return err } - if err := run.LoadRepo(ctx); err != nil { - return err + runAttempt := &actions_model.ActionRunAttempt{ + RepoID: run.RepoID, + RunID: run.ID, + Attempt: 1, + TriggerUserID: run.TriggerUserID, + Status: actions_model.StatusWaiting, } - if err := actions_model.UpdateRepoRunsNumbers(ctx, run.Repo); err != nil { + if wfRawConcurrency != nil { + if err := EvaluateRunConcurrencyFillModel(ctx, run, runAttempt, wfRawConcurrency, vars, inputs); err != nil { + return fmt.Errorf("EvaluateRunConcurrencyFillModel: %w", err) + } + // check run (workflow-level) concurrency + var jobsToCancel []*actions_model.ActionRunJob + runAttempt.Status, jobsToCancel, err = PrepareToStartRunWithConcurrency(ctx, runAttempt) + if err != nil { + return err + } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) + } + + if err := db.Insert(ctx, runAttempt); err != nil { + return err + } + run.LatestAttemptID = runAttempt.ID + + giteaCtx := GenerateGiteaContext(ctx, run, runAttempt, nil) + jobs, err := jobparser.Parse(content, jobparser.WithVars(vars), jobparser.WithGitContext(giteaCtx.ToGitHubContext()), jobparser.WithInputs(inputs)) + if err != nil { + return fmt.Errorf("parse workflow: %w", err) + } + titleChanged := len(jobs) > 0 && jobs[0].RunName != "" + if titleChanged { + run.Title = util.EllipsisDisplayString(jobs[0].RunName, 255) + } + + cols := []string{"latest_attempt_id"} + if titleChanged { + cols = append(cols, "title") + } + if err := actions_model.UpdateRun(ctx, run, cols...); err != nil { return err } runJobs := make([]*actions_model.ActionRunJob, 0, len(jobs)) var hasWaitingJobs bool - for _, v := range jobs { + for i, v := range jobs { id, job := v.Job() needs := job.Needs() if err := v.SetJob(id, job.EraseNeeds()); err != nil { @@ -112,18 +136,21 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar } payload, _ := v.Marshal() - shouldBlockJob := len(needs) > 0 || run.NeedApproval || run.Status == actions_model.StatusBlocked + shouldBlockJob := runAttempt.Status == actions_model.StatusBlocked || len(needs) > 0 || run.NeedApproval job.Name = util.EllipsisDisplayString(job.Name, 255) runJob := &actions_model.ActionRunJob{ RunID: run.ID, + RunAttemptID: runAttempt.ID, RepoID: run.RepoID, OwnerID: run.OwnerID, CommitSHA: run.CommitSHA, IsForkPullRequest: run.IsForkPullRequest, Name: job.Name, + Attempt: runAttempt.Attempt, WorkflowPayload: payload, JobID: id, + AttemptJobID: int64(i + 1), Needs: needs, RunsOn: job.RunsOn(), Status: util.Iif(shouldBlockJob, actions_model.StatusBlocked, actions_model.StatusWaiting), @@ -143,7 +170,7 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar // do not evaluate job concurrency when it requires `needs`, the jobs with `needs` will be evaluated later by job emitter if len(needs) == 0 { - err = EvaluateJobConcurrencyFillModel(ctx, run, runJob, vars, inputs) + err = EvaluateJobConcurrencyFillModel(ctx, run, runAttempt, runJob, vars, inputs) if err != nil { return fmt.Errorf("evaluate job concurrency: %w", err) } @@ -152,10 +179,12 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar // If a job needs other jobs ("needs" is not empty), its status is set to StatusBlocked at the entry of the loop // No need to check job concurrency for a blocked job (it will be checked by job emitter later) if runJob.Status == actions_model.StatusWaiting { - runJob.Status, err = PrepareToStartJobWithConcurrency(ctx, runJob) + var jobsToCancel []*actions_model.ActionRunJob + runJob.Status, jobsToCancel, err = PrepareToStartJobWithConcurrency(ctx, runJob) if err != nil { return fmt.Errorf("prepare to start job with concurrency: %w", err) } + cancelledConcurrencyJobs = append(cancelledConcurrencyJobs, jobsToCancel...) } } @@ -167,8 +196,8 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar runJobs = append(runJobs, runJob) } - run.Status = actions_model.AggregateJobStatus(runJobs) - if err := actions_model.UpdateRun(ctx, run, "status"); err != nil { + runAttempt.Status = actions_model.AggregateJobStatus(runJobs) + if err := actions_model.UpdateRunAttempt(ctx, runAttempt, "status"); err != nil { return err } @@ -180,5 +209,12 @@ func InsertRun(ctx context.Context, run *actions_model.ActionRun, jobs []*jobpar } return nil - }) + }); err != nil { + return err + } + + NotifyWorkflowJobsAndRunsStatusUpdate(ctx, cancelledConcurrencyJobs) + EmitJobsIfReadyByJobs(cancelledConcurrencyJobs) + + return nil } diff --git a/services/actions/schedule_tasks.go b/services/actions/schedule_tasks.go index 037bf5cddd1..b2dc3f98407 100644 --- a/services/actions/schedule_tasks.go +++ b/services/actions/schedule_tasks.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" webhook_module "code.gitea.io/gitea/modules/webhook" @@ -67,7 +68,7 @@ func startTasks(ctx context.Context) error { continue } - if err := CreateScheduleTask(ctx, row.Schedule); err != nil { + if err := CreateScheduleTask(ctx, row); err != nil { log.Error("CreateScheduleTask: %v", err) return err } @@ -97,9 +98,12 @@ func startTasks(ctx context.Context) error { return nil } -// CreateScheduleTask creates a scheduled task from a cron action schedule. +// CreateScheduleTask creates a scheduled task from a cron action schedule spec. // It creates an action run based on the schedule, inserts it into the database, and creates commit statuses for each job. -func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) error { +func CreateScheduleTask(ctx context.Context, spec *actions_model.ActionScheduleSpec) error { + cron := spec.Schedule + eventPayload := withScheduleInEventPayload(cron.EventPayload, spec.Spec) + // Create a new action run based on the schedule run := &actions_model.ActionRun{ Title: cron.Title, @@ -110,7 +114,7 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) Ref: cron.Ref, CommitSHA: cron.CommitSHA, Event: cron.Event, - EventPayload: cron.EventPayload, + EventPayload: eventPayload, TriggerEvent: string(webhook_module.HookEventSchedule), ScheduleID: cron.ID, Status: actions_model.StatusWaiting, @@ -126,3 +130,24 @@ func CreateScheduleTask(ctx context.Context, cron *actions_model.ActionSchedule) // Return nil if no errors occurred return nil } + +func withScheduleInEventPayload(eventPayload, schedule string) string { + if schedule == "" || eventPayload == "" { + return eventPayload + } + + event := map[string]any{} + if err := json.Unmarshal([]byte(eventPayload), &event); err != nil { + log.Error("withScheduleInEventPayload: unmarshal: %v", err) + return eventPayload + } + + event["schedule"] = schedule + updatedPayload, err := json.Marshal(event) + if err != nil { + log.Error("withScheduleInEventPayload: marshal: %v", err) + return eventPayload + } + + return string(updatedPayload) +} diff --git a/services/actions/schedule_tasks_test.go b/services/actions/schedule_tasks_test.go new file mode 100644 index 00000000000..770b8426232 --- /dev/null +++ b/services/actions/schedule_tasks_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "code.gitea.io/gitea/modules/json" + + "github.com/stretchr/testify/assert" +) + +func TestWithScheduleInEventPayload(t *testing.T) { + t.Run("adds schedule to existing payload", func(t *testing.T) { + payload := `{"ref":"refs/heads/main"}` + updated := withScheduleInEventPayload(payload, "*/5 * * * *") + + event := map[string]any{} + assert.NoError(t, json.Unmarshal([]byte(updated), &event)) + assert.Equal(t, "*/5 * * * *", event["schedule"]) + assert.Equal(t, "refs/heads/main", event["ref"]) + }) + + t.Run("keeps empty payload", func(t *testing.T) { + updated := withScheduleInEventPayload("", "37 12 5 1 2") + assert.Empty(t, updated) + }) + + t.Run("keeps payload when schedule empty", func(t *testing.T) { + payload := `{"ref":"refs/heads/main"}` + updated := withScheduleInEventPayload(payload, "") + assert.Equal(t, payload, updated) + }) + + t.Run("keeps payload when malformed JSON", func(t *testing.T) { + payload := `not a json object` + updated := withScheduleInEventPayload(payload, "*/5 * * * *") + assert.Equal(t, payload, updated) + }) +} diff --git a/services/actions/task.go b/services/actions/task.go index 2cb10b6cd8f..9dc3c9a34b7 100644 --- a/services/actions/task.go +++ b/services/actions/task.go @@ -11,7 +11,6 @@ import ( actions_model "code.gitea.io/gitea/models/actions" "code.gitea.io/gitea/models/db" secret_model "code.gitea.io/gitea/models/secret" - notify_service "code.gitea.io/gitea/services/notify" runnerv1 "code.gitea.io/actions-proto-go/runner/v1" "google.golang.org/protobuf/types/known/structpb" @@ -78,7 +77,7 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv return fmt.Errorf("findTaskNeeds: %w", err) } - taskContext, err := generateTaskContext(t) + taskContext, err := generateTaskContext(ctx, t) if err != nil { return fmt.Errorf("generateTaskContext: %w", err) } @@ -102,23 +101,23 @@ func PickTask(ctx context.Context, runner *actions_model.ActionRunner) (*runnerv } CreateCommitStatusForRunJobs(ctx, job.Run, job) - notify_service.WorkflowJobStatusUpdate(ctx, job.Run.Repo, job.Run.TriggerUser, job, actionTask) + NotifyWorkflowJobStatusUpdateWithTask(ctx, job, actionTask) // job.Run is loaded inside the transaction before UpdateRunJob sets run.Started, // so Started is zero only on the very first pick-up of that run. if job.Run.Started.IsZero() { - NotifyWorkflowRunStatusUpdateWithReload(ctx, job) + NotifyWorkflowRunStatusUpdateWithReload(ctx, job.RepoID, job.RunID) } return task, true, nil } -func generateTaskContext(t *actions_model.ActionTask) (*structpb.Struct, error) { +func generateTaskContext(ctx context.Context, t *actions_model.ActionTask) (*structpb.Struct, error) { giteaRuntimeToken, err := CreateAuthorizationToken(t.ID, t.Job.RunID, t.JobID) if err != nil { return nil, err } - gitCtx := GenerateGiteaContext(t.Job.Run, t.Job) + gitCtx := GenerateGiteaContext(ctx, t.Job.Run, nil, t.Job) gitCtx["token"] = t.Token gitCtx["gitea_runtime_token"] = giteaRuntimeToken diff --git a/services/actions/variables.go b/services/actions/variables.go index 57e6af1d9ba..3593caa2c5e 100644 --- a/services/actions/variables.go +++ b/services/actions/variables.go @@ -16,7 +16,7 @@ func CreateVariable(ctx context.Context, ownerID, repoID int64, name, data, desc return nil, err } - v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.ReserveLineBreakForTextarea(data), description) + v, err := actions_model.InsertVariable(ctx, ownerID, repoID, name, util.NormalizeStringEOL(data), description) if err != nil { return nil, err } @@ -29,7 +29,7 @@ func UpdateVariableNameData(ctx context.Context, variable *actions_model.ActionV return false, err } - variable.Data = util.ReserveLineBreakForTextarea(variable.Data) + variable.Data = util.NormalizeStringEOL(variable.Data) return actions_model.UpdateVariableCols(ctx, variable, "name", "data", "description") } diff --git a/services/asymkey/sign.go b/services/asymkey/sign.go index cffefe08ae6..8c28717e5d0 100644 --- a/services/asymkey/sign.go +++ b/services/asymkey/sign.go @@ -338,26 +338,41 @@ Loop: return false, nil, nil, &ErrWontSign{headSigned} } case commitsSigned: - verification := ParseCommitWithSignature(ctx, headCommit) - if !verification.Verified { + verified, err := AllHeadCommitsVerified(ctx, pr, gitRepo) + if err != nil { + return false, nil, nil, err + } + if !verified { return false, nil, nil, &ErrWontSign{commitsSigned} } - // need to work out merge-base - mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String()) - if err != nil { - return false, nil, nil, err - } - commitList, err := headCommit.CommitsBeforeUntil(mergeBaseCommit) - if err != nil { - return false, nil, nil, err - } - for _, commit := range commitList { - verification := ParseCommitWithSignature(ctx, commit) - if !verification.Verified { - return false, nil, nil, &ErrWontSign{commitsSigned} - } - } } } return true, signingKey, signer, nil } + +// AllHeadCommitsVerified checks that every new commit in the PR head has a +// verified signature. +func AllHeadCommitsVerified(ctx context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) (bool, error) { + baseCommit, err := gitRepo.GetCommit(pr.BaseBranch) + if err != nil { + return false, err + } + headCommit, err := gitRepo.GetCommit(pr.GetGitHeadRefName()) + if err != nil { + return false, err + } + mergeBaseCommit, err := gitrepo.MergeBase(ctx, pr.BaseRepo, baseCommit.ID.String(), headCommit.ID.String()) + if err != nil { + return false, err + } + commitList, err := headCommit.CommitsBeforeUntil(mergeBaseCommit) + if err != nil { + return false, err + } + for _, commit := range commitList { + if !ParseCommitWithSignature(ctx, commit).Verified { + return false, nil + } + } + return true, nil +} diff --git a/services/auth/auth_token.go b/services/auth/auth_token.go index 8897bbd19ca..7fcf3ba0dff 100644 --- a/services/auth/auth_token.go +++ b/services/auth/auth_token.go @@ -64,10 +64,7 @@ func CheckAuthToken(ctx context.Context, value string) (*auth_model.AuthToken, e } func RegenerateAuthToken(ctx context.Context, t *auth_model.AuthToken) (*auth_model.AuthToken, string, error) { - token, hash, err := generateTokenAndHash() - if err != nil { - return nil, "", err - } + token, hash := generateTokenAndHash() newToken := &auth_model.AuthToken{ ID: t.ID, @@ -89,16 +86,9 @@ func CreateAuthTokenForUserID(ctx context.Context, userID int64) (*auth_model.Au ExpiresUnix: timeutil.TimeStampNow().AddDuration(time.Duration(setting.LogInRememberDays*24) * time.Hour), } - var err error - t.ID, err = util.CryptoRandomString(10) - if err != nil { - return nil, "", err - } + t.ID = util.CryptoRandomString(10) - token, hash, err := generateTokenAndHash() - if err != nil { - return nil, "", err - } + token, hash := generateTokenAndHash() t.TokenHash = hash @@ -109,15 +99,12 @@ func CreateAuthTokenForUserID(ctx context.Context, userID int64) (*auth_model.Au return t, token, nil } -func generateTokenAndHash() (string, string, error) { - buf, err := util.CryptoRandomBytes(32) - if err != nil { - return "", "", err - } +func generateTokenAndHash() (string, string) { + buf := util.CryptoRandomBytes(32) token := hex.EncodeToString(buf) hashedToken := sha256.Sum256([]byte(token)) - return token, hex.EncodeToString(hashedToken[:]), nil + return token, hex.EncodeToString(hashedToken[:]) } diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go index fc0d77a7e61..557fe6cb013 100644 --- a/services/auth/source/oauth2/providers_openid.go +++ b/services/auth/source/oauth2/providers_openid.go @@ -46,8 +46,14 @@ func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, so provider, err := openidConnect.New(source.ClientID, source.ClientSecret, callbackURL, source.OpenIDConnectAutoDiscoveryURL, scopes...) if err != nil { log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err) + return nil, err } - return provider, err + if source.ExternalIDClaim != "" { + // UserIdClaims is a fallback list; goth returns the first non-empty matching claim. + // A single entry is sufficient because the admin explicitly chooses one claim (e.g. "oid" for Azure AD). + provider.UserIdClaims = []string{source.ExternalIDClaim} + } + return provider, nil } // CustomURLSettings returns the custom url settings for this provider diff --git a/services/auth/source/oauth2/source.go b/services/auth/source/oauth2/source.go index 00d89b3481b..3f69c08fab8 100644 --- a/services/auth/source/oauth2/source.go +++ b/services/auth/source/oauth2/source.go @@ -30,6 +30,7 @@ type Source struct { SSHPublicKeyClaimName string FullNameClaimName string + ExternalIDClaim string } // FromDB fills up an OAuth2Config from serialized format. diff --git a/services/automerge/automerge.go b/services/automerge/automerge.go index b3a988320bc..1629b2b95e5 100644 --- a/services/automerge/automerge.go +++ b/services/automerge/automerge.go @@ -90,7 +90,7 @@ func RemoveScheduledAutoMerge(ctx context.Context, doer *user_model.User, pull * // StartPRCheckAndAutoMergeBySHA start an automerge check and auto merge task for all pull requests of repository and SHA func StartPRCheckAndAutoMergeBySHA(ctx context.Context, sha string, repo *repo_model.Repository) error { pulls, err := getPullRequestsByHeadSHA(ctx, sha, repo, func(pr *issues_model.PullRequest) bool { - return !pr.HasMerged && pr.CanAutoMerge() + return !pr.HasMerged && pr.IsStatusMergeable() }) if err != nil { return err @@ -251,7 +251,7 @@ func handlePullRequestAutoMerge(pullID int64, sha string) { return } - if err := pull_service.CheckPullMergeable(ctx, doer, &perm, pr, pull_service.MergeCheckTypeGeneral, false); err != nil { + if err := pull_service.CheckPullMergeable(ctx, doer, &perm, pr, pull_service.MergeCheckTypeGeneral, scheduledPRM.MergeStyle, false); err != nil { if errors.Is(err, pull_service.ErrNotReadyToMerge) { log.Info("%-v was scheduled to automerge by an unauthorized user", pr) return diff --git a/services/automergequeue/automergequeue.go b/services/automergequeue/automergequeue.go index e8cc4512a7e..8cfdf3a5398 100644 --- a/services/automergequeue/automergequeue.go +++ b/services/automergequeue/automergequeue.go @@ -25,7 +25,7 @@ var AddToQueue = func(pr *issues_model.PullRequest, sha string) { // StartPRCheckAndAutoMerge start an automerge check and auto merge task for a pull request func StartPRCheckAndAutoMerge(ctx context.Context, pull *issues_model.PullRequest) { - if pull == nil || pull.HasMerged || !pull.CanAutoMerge() { + if pull == nil || pull.HasMerged || !pull.IsStatusMergeable() { return } diff --git a/services/context/api.go b/services/context/api.go index b49bf9b42c6..3f9f3e1cdd2 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -322,24 +322,6 @@ func RepoRefForAPI(next http.Handler) http.Handler { }) } -// HasAPIError returns true if error occurs in form validation. -func (ctx *APIContext) HasAPIError() bool { - hasErr, ok := ctx.Data["HasError"] - if !ok { - return false - } - return hasErr.(bool) -} - -// GetErrMsg returns error message in form validation. -func (ctx *APIContext) GetErrMsg() string { - msg, _ := ctx.Data["ErrorMsg"].(string) - if msg == "" { - msg = "invalid form data" - } - return msg -} - // NotFoundOrServerError use error check function to determine if the error // is about not found. It responds with 404 status code for not found error, // or error context description for logging purpose of 500 server error. @@ -358,10 +340,10 @@ func (ctx *APIContext) IsUserSiteAdmin() bool { // IsUserRepoAdmin returns true if current user is admin in current repo func (ctx *APIContext) IsUserRepoAdmin() bool { - return ctx.Repo.IsAdmin() + return ctx.Repo.Permission.IsAdmin() } // IsUserRepoWriter returns true if current user has "write" privilege in current repo func (ctx *APIContext) IsUserRepoWriter(unitTypes []unit.Type) bool { - return slices.ContainsFunc(unitTypes, ctx.Repo.CanWrite) + return slices.ContainsFunc(unitTypes, ctx.Repo.Permission.CanWrite) } diff --git a/services/context/base.go b/services/context/base.go index 8d44de5bc72..c5ec4b419a2 100644 --- a/services/context/base.go +++ b/services/context/base.go @@ -159,12 +159,10 @@ func (b *Base) Redirect(location string, status ...int) { // So in this case, we should remove the session cookie from the response header removeSessionCookieHeader(b.Resp) } - // in case the request is made by htmx, have it redirect the browser instead of trying to follow the redirect inside htmx - if b.Req.Header.Get("HX-Request") == "true" { - b.Resp.Header().Set("HX-Redirect", location) - // we have to return a non-redirect status code so XMLHTTPRequest will not immediately follow the redirect - // so as to give htmx redirect logic a chance to run - b.Status(http.StatusNoContent) + // In case the request is made by "fetch-action" module, make JS redirect to the new location + // Otherwise, the JS fetch will follow the redirection and read a "login" page, embed it to the current page, which is not expected. + if b.Req.Header.Get("X-Gitea-Fetch-Action") != "" { + b.JSON(http.StatusOK, map[string]any{"redirect": location}) return } http.Redirect(b.Resp, b.Req, location, code) diff --git a/services/context/base_path.go b/services/context/base_path.go index 63e60c8654e..8c353f7acaf 100644 --- a/services/context/base_path.go +++ b/services/context/base_path.go @@ -44,9 +44,9 @@ func (b *Base) PathParamInt(p string) int { // SetPathParam set request path params into routes func (b *Base) SetPathParam(name, value string) { - if strings.HasPrefix(name, ":") { - setting.PanicInDevOrTesting("path param should not start with ':'") - name = name[1:] - } chi.RouteContext(b).URLParams.Add(name, url.PathEscape(value)) } + +func (b *Base) SetPathParamRaw(name, value string) { + chi.RouteContext(b).URLParams.Add(name, value) +} diff --git a/services/context/base_test.go b/services/context/base_test.go index 2a4f86dddf8..f9bbe717290 100644 --- a/services/context/base_test.go +++ b/services/context/base_test.go @@ -38,9 +38,10 @@ func TestRedirect(t *testing.T) { req, _ = http.NewRequest(http.MethodGet, "/", nil) resp := httptest.NewRecorder() - req.Header.Add("HX-Request", "true") + req.Header.Add("X-Gitea-Fetch-Action", "1") b := NewBaseContextForTest(resp, req) b.Redirect("/other") - assert.Equal(t, "/other", resp.Header().Get("HX-Redirect")) - assert.Equal(t, http.StatusNoContent, resp.Code) + assert.Contains(t, resp.Header().Get("Content-Type"), "application/json") + assert.JSONEq(t, `{"redirect":"/other"}`, resp.Body.String()) + assert.Equal(t, http.StatusOK, resp.Code) } diff --git a/services/context/context.go b/services/context/context.go index a6a861ecaa6..e8b1663b221 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -63,8 +63,6 @@ type Context struct { Package *Package } -type TemplateContext map[string]any - func init() { web.RegisterResponseStatusProvider[*Base](func(req *http.Request) web_types.ResponseStatusProvider { return req.Context().Value(BaseContextKey).(*Base) @@ -165,6 +163,7 @@ func Contexter() func(next http.Handler) http.Handler { base := NewBaseContext(resp, req) ctx := NewWebContext(base, rnd, session.GetContextSession(req)) ctx.Data.MergeFrom(middleware.CommonTemplateContextData()) + ctx.Data["CurrentURL"] = setting.AppSubURL + req.URL.RequestURI() ctx.Data["Link"] = ctx.Link // PageData is passed by reference, and it will be rendered to `window.config.pageData` in `head.tmpl` for JavaScript modules @@ -198,10 +197,6 @@ func Contexter() func(next http.Handler) http.Handler { httpcache.SetCacheControlInHeader(ctx.Resp.Header(), &httpcache.CacheControlOptions{NoTransform: true}) - if setting.Security.XFrameOptions != "unset" { - ctx.Resp.Header().Set(`X-Frame-Options`, setting.Security.XFrameOptions) - } - ctx.Data["SystemConfig"] = setting.Config() ctx.Data["ShowTwoFactorRequiredMessage"] = ctx.DoerNeedTwoFactorAuth() @@ -211,7 +206,6 @@ func Contexter() func(next http.Handler) http.Handler { ctx.Data["DisableStars"] = setting.Repository.DisableStars ctx.Data["EnableActions"] = setting.Actions.Enabled && !unit.TypeActions.UnitGlobalDisabled() - ctx.Data["ManifestData"] = setting.ManifestData ctx.Data["AllLangs"] = translation.AllLangs() next.ServeHTTP(ctx.Resp, ctx.Req) diff --git a/services/context/context_response.go b/services/context/context_response.go index d057f8d41eb..1fd7a0b447f 100644 --- a/services/context/context_response.go +++ b/services/context/context_response.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" ) @@ -143,11 +144,9 @@ func (ctx *Context) NotFound(logErr error) { } func (ctx *Context) notFoundInternal(logMsg string, logErr error) { + // TODO: it's safe to show the error message to end users if the error is fully controlled by our error system if logErr != nil { log.Log(2, log.DEBUG, "%s: %v", logMsg, logErr) - if !setting.IsProd { - ctx.Data["ErrorMsg"] = logErr - } } // response simple message if Accept isn't text/html @@ -166,11 +165,17 @@ func (ctx *Context) notFoundInternal(logMsg string, logErr error) { ctx.Data["IsRepo"] = ctx.Repo.Repository != nil ctx.Data["Title"] = "Page Not Found" + ctx.Data["ErrorMsg"] = "" // FIXME: the template never renders this message, need to fix in the future (and show safe messages to end users) ctx.HTML(http.StatusNotFound, "status/404") } // ServerError displays a 500 (Internal Server Error) page and prints the given error, if any. +// If the error is controlled by our error system, a related 404 page can be displayed instead. func (ctx *Context) ServerError(logMsg string, logErr error) { + if errors.Is(logErr, util.ErrNotExist) { + ctx.notFoundInternal(logMsg, logErr) + return + } ctx.serverErrorInternal(logMsg, logErr) } diff --git a/services/context/context_template.go b/services/context/context_template.go index 4e28c0f7dfd..b63aaf4c3c3 100644 --- a/services/context/context_template.go +++ b/services/context/context_template.go @@ -5,18 +5,25 @@ package context import ( "context" + "fmt" + "html" "html/template" "net/http" "strconv" "strings" + "sync" "time" "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/webtheme" ) +type TemplateContext map[string]any + var _ context.Context = TemplateContext(nil) func NewTemplateContext(ctx context.Context, req *http.Request) TemplateContext { @@ -81,5 +88,73 @@ func (c TemplateContext) AppFullLink(link ...string) template.URL { if len(link) == 0 { return template.URL(s) } - return template.URL(s + strings.TrimPrefix(link[0], "/")) + return template.URL(s + "/" + strings.TrimPrefix(link[0], "/")) +} + +var globalVars = sync.OnceValue(func() (ret struct { + scriptImportRemainingPart string +}, +) { + // add onerror handler to alert users when the script fails to load: + // * for end users: there were many users reporting that "UI doesn't work", actually they made mistakes in their config + // * for developers: help them to remember to run "make watch-frontend" to build frontend assets + // the message will be directly put in the onerror JS code's string + onScriptErrorPrompt := `Please make sure the asset files can be accessed.` + if !setting.IsProd { + onScriptErrorPrompt += `\n\nFor development, run: make watch-frontend.` + } + onScriptErrorJS := fmt.Sprintf(`alert('Failed to load asset file from ' + this.src + '. %s')`, onScriptErrorPrompt) + ret.scriptImportRemainingPart = `onerror="` + html.EscapeString(onScriptErrorJS) + `">` + return ret +}) + +func (c TemplateContext) ScriptImport(path string, typ ...string) template.HTML { + if len(typ) > 0 { + if typ[0] == "module" { + return template.HTML(` -{{ScriptImport "js/iife.js"}} +{{ctx.ScriptImport "js/iife.js"}} diff --git a/templates/devtest/devtest-header.tmpl b/templates/devtest/devtest-header.tmpl index 628e4388a0c..c9d7b3047fe 100644 --- a/templates/devtest/devtest-header.tmpl +++ b/templates/devtest/devtest-header.tmpl @@ -1,4 +1,4 @@ {{template "base/head" ctx.RootData}}
-{{template "base/alert" .}} +
{{template "base/alert" ctx.RootData}}
diff --git a/templates/devtest/fetch-action.tmpl b/templates/devtest/fetch-action.tmpl index 4ee824f04be..e8fddf17b09 100644 --- a/templates/devtest/fetch-action.tmpl +++ b/templates/devtest/fetch-action.tmpl @@ -1,10 +1,9 @@ {{template "devtest/devtest-header"}}
- {{template "base/alert" .}}

link-action

- Use "window.fetch" to send a request to backend, the request is defined in an "A" or "BUTTON" element. + The request is defined in an "A" or "BUTTON" element. It might be renamed to "link-fetch-action" to match the "form-fetch-action".
@@ -16,30 +15,20 @@

form-fetch-action

-
Use "window.fetch" to send a form request to backend
-
-
+
+ -
+
-
+
bad action url
- {{template "devtest/devtest-footer"}} diff --git a/templates/devtest/fomantic-modal.tmpl b/templates/devtest/fomantic-modal.tmpl index 8e769790b25..98c3f332ae7 100644 --- a/templates/devtest/fomantic-modal.tmpl +++ b/templates/devtest/fomantic-modal.tmpl @@ -1,21 +1,9 @@ {{template "devtest/devtest-header"}}
- {{template "base/alert" .}}
- -
Form dialog (layout 1)
-
+
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
@@ -23,7 +11,7 @@
Form dialog (layout 2)
-
+
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}} @@ -33,7 +21,7 @@
Form dialog (layout 3)
- +
@@ -46,7 +34,7 @@
- + {{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
@@ -54,7 +42,7 @@
Form dialog (layout 5)
-
+
{{template "base/modal_actions_confirm" (dict "ModalButtonTypes" "confirm")}}
diff --git a/templates/devtest/form-fields.tmpl b/templates/devtest/form-fields.tmpl new file mode 100644 index 00000000000..ee6df2e813f --- /dev/null +++ b/templates/devtest/form-fields.tmpl @@ -0,0 +1,109 @@ +{{template "devtest/devtest-header"}} +
+
+

Input

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

Textarea

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

Dropdown

+
+ +
+ + {{svg "octicon-triangle-down" 14 "dropdown icon"}} +
Option A
+
+
Option A
+
Option B
+
+
+
+
+ +
+ + {{svg "octicon-triangle-down" 14 "dropdown icon"}} +
Option A
+
+
Option A
+
Option B
+
+
+
+
+ +
+ + {{svg "octicon-triangle-down" 14 "dropdown icon"}} +
Option A
+
+
Option A
+
Option B
+
+
+
+
+ +
+ + {{svg "octicon-triangle-down" 14 "dropdown icon"}} +
Option A
+
+
Option A
+
Option B
+
+
+
+ +

Required

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+{{template "devtest/devtest-footer"}} diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl index 1584792b6bf..cec58ed3370 100644 --- a/templates/devtest/gitea-ui.tmpl +++ b/templates/devtest/gitea-ui.tmpl @@ -45,17 +45,6 @@
-
diff --git a/templates/devtest/relative-time.tmpl b/templates/devtest/relative-time.tmpl index 041ce49f09f..f4c664e26f3 100644 --- a/templates/devtest/relative-time.tmpl +++ b/templates/devtest/relative-time.tmpl @@ -38,6 +38,7 @@
numeric:
weekday:
with time:
+
minutes:

Threshold

diff --git a/templates/devtest/repo-action-view.tmpl b/templates/devtest/repo-action-view.tmpl index 46f040d8a6f..2971039fc94 100644 --- a/templates/devtest/repo-action-view.tmpl +++ b/templates/devtest/repo-action-view.tmpl @@ -3,12 +3,12 @@
Run:CanCancel Run:CanApprove - Run:CanRerun + Run:CanRerunLatest + Run:PreviousAttempt
{{template "repo/actions/view_component" (dict - "RunID" (or .RunID 10) "JobID" (or .JobID 0) - "ActionsURL" (print AppSubUrl "/devtest/repo-action-view") + "ActionsViewURL" $.ActionsViewURL )}}
{{template "base/footer" .}} diff --git a/templates/devtest/severity-colors.tmpl b/templates/devtest/severity-colors.tmpl index 9f86b864ea9..43a51614655 100644 --- a/templates/devtest/severity-colors.tmpl +++ b/templates/devtest/severity-colors.tmpl @@ -20,6 +20,25 @@

This is an info message using --color-info-* variables.

+

Markdown Attention Blocks

+
+

{{svg "octicon-info" 16 "attention-icon attention-note"}}Note

+

Useful information that users should know, even when skimming content.

+
+

{{svg "octicon-light-bulb" 16 "attention-icon attention-tip"}}Tip

+

Helpful advice for doing things better or more easily.

+
+

{{svg "octicon-report" 16 "attention-icon attention-important"}}Important

+

Key information users need to know to achieve their goal.

+
+

{{svg "octicon-alert" 16 "attention-icon attention-warning"}}Warning

+

Urgent info that needs immediate user attention to avoid problems.

+
+

{{svg "octicon-stop" 16 "attention-icon attention-caution"}}Caution

+

Advises about risks or negative outcomes of certain actions.

+
+
+

Form Fields

@@ -28,6 +47,22 @@
+

Error Input

+
+ +
+ +

Attached Section Boxes

+

Error Header

+
Error section body content.
+

Warning Header

+
Warning section body content.
+ +

Banner Preview (info-tinted)

+
+
Banner preview content
+
+

Labels

Red
diff --git a/templates/devtest/toast-and-message.tmpl b/templates/devtest/toast-and-message.tmpl new file mode 100644 index 00000000000..484110ef1bf --- /dev/null +++ b/templates/devtest/toast-and-message.tmpl @@ -0,0 +1,14 @@ +{{template "devtest/devtest-header"}} +
+
+
+

Toast

+
+ + + + +
+
+
+{{template "devtest/devtest-footer"}} diff --git a/templates/devtest/toast.tmpl b/templates/devtest/toast.tmpl deleted file mode 100644 index 597b4154695..00000000000 --- a/templates/devtest/toast.tmpl +++ /dev/null @@ -1,11 +0,0 @@ -{{template "devtest/devtest-header"}} -
-

Toast

-
- - - - -
-
-{{template "devtest/devtest-footer"}} diff --git a/templates/install.tmpl b/templates/install.tmpl index 45f14d5c575..bc6fed08e95 100644 --- a/templates/install.tmpl +++ b/templates/install.tmpl @@ -117,7 +117,7 @@ {{ctx.Locale.Tr "install.lfs_path_helper"}}
-
+
{{ctx.Locale.Tr "install.run_user_helper"}} diff --git a/templates/org/follow_unfollow.tmpl b/templates/org/follow_unfollow.tmpl index ba0bd01efe4..77977c12794 100644 --- a/templates/org/follow_unfollow.tmpl +++ b/templates/org/follow_unfollow.tmpl @@ -1,4 +1,4 @@ -
- {{range .Teams}} + {{range .OrgOverviewTeams}}
{{.Name}}

diff --git a/templates/org/team/new.tmpl b/templates/org/team/new.tmpl index abf728fc544..f8785bb466a 100644 --- a/templates/org/team/new.tmpl +++ b/templates/org/team/new.tmpl @@ -20,7 +20,7 @@

- + {{ctx.Locale.Tr "org.team_desc_helper"}}
{{if not (eq .Team.LowerName "owners")}} diff --git a/templates/org/team/sidebar.tmpl b/templates/org/team/sidebar.tmpl index 8678ed74544..1487c280dee 100644 --- a/templates/org/team/sidebar.tmpl +++ b/templates/org/team/sidebar.tmpl @@ -1,17 +1,16 @@
-

+

{{.Team.Name}} -
+
{{if .Team.IsMember ctx $.SignedUser.ID}} -
- -
+ {{else if .IsOrganizationOwner}}
- +
{{end}}
@@ -85,12 +84,12 @@
{{end}}

-
+
{{ctx.Locale.Tr "org.teams.leave"}}
-
-

{{ctx.Locale.Tr "org.teams.leave.detail" (HTMLFormat `` "name")}}

-
- {{template "base/modal_actions_confirm" .}} +
+

{{ctx.Locale.Tr "org.teams.leave.detail" (HTMLFormat `` "to-leave-team-name")}}

+ {{template "base/modal_actions_confirm" .}} +
diff --git a/templates/org/team/teams.tmpl b/templates/org/team/teams.tmpl index 5ea15068fe4..48b404e17b6 100644 --- a/templates/org/team/teams.tmpl +++ b/templates/org/team/teams.tmpl @@ -8,49 +8,59 @@
{{ctx.Locale.Tr "org.teams.manage_team_member_prompt"}}
{{svg "octicon-plus"}} {{ctx.Locale.Tr "org.create_new_team"}}
-
{{end}} +
+
+ + +
+
+
- {{range .Teams}} -
-
- {{.Name}} -
- {{ctx.Locale.Tr "view"}} + {{range $team := $.OrgListTeams}} +
+
+ {{.Name}} +
+ {{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}} + · + {{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}} {{if .IsMember ctx $.SignedUser.ID}} -
- -
- {{else if $.IsOrganizationOwner}} -
- -
+ {{end}}
-
- {{range .Members}} - {{template "shared/user/avatarlink" dict "user" .}} - {{end}} + {{if $team.Description}} +
+ {{if $team.Description}}{{$team.Description}}{{end}}
-
-

{{.NumMembers}} {{ctx.Locale.Tr "org.lower_members"}} · {{.NumRepos}} {{ctx.Locale.Tr "org.lower_repositories"}}

+ {{end}} +
+
+ {{range .Members}} + {{template "shared/user/avatarlink" dict "user" . "size" 32 "tooltip" true}} + {{else}} + {{ctx.Locale.Tr "org.teams.add_team_member"}} + {{end}} +
{{end}}
+ {{template "base/paginate" .}}
-
+
{{ctx.Locale.Tr "org.teams.leave"}}
-
-

{{ctx.Locale.Tr "org.teams.leave.detail" (HTMLFormat `` "name")}}

-
- {{template "base/modal_actions_confirm" .}} +
+

{{ctx.Locale.Tr "org.teams.leave.detail" (HTMLFormat `` "to-leave-team-name")}}

+ {{template "base/modal_actions_confirm" .}} +
{{template "base/footer" .}} diff --git a/templates/package/content/terraform.tmpl b/templates/package/content/terraform.tmpl index 6006bee9aa7..edc8825d1f8 100644 --- a/templates/package/content/terraform.tmpl +++ b/templates/package/content/terraform.tmpl @@ -6,7 +6,7 @@
terraform {
 	backend "http" {
-		address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}""
+		address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}"
 		lock_address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}/lock"
 		unlock_address = "{{ctx.AppFullLink}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/terraform/state/{{$.PackageDescriptor.Package.Name}}/lock"
 		lock_method = "POST"
diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl
index 6a5fbc5a0b8..346f4002b40 100644
--- a/templates/projects/list.tmpl
+++ b/templates/projects/list.tmpl
@@ -1,5 +1,5 @@
 {{if and $.CanWriteProjects (not $.Repository.IsArchived)}}
-	
+
{{svg "octicon-project-symlink" 16 "tw-mr-2"}} diff --git a/templates/projects/view.tmpl b/templates/projects/view.tmpl index ac08e567813..30056e211f1 100644 --- a/templates/projects/view.tmpl +++ b/templates/projects/view.tmpl @@ -108,7 +108,7 @@ > {{svg "octicon-star"}} {{ctx.Locale.Tr "repo.projects.column.set_default"}} - diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index f31ef1a73a0..4fbc58592a0 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -25,49 +25,49 @@
-
- {{ctx.Locale.TrN .Page.Paginater.Total "actions.runs.workflow_run_count_1" "actions.runs.workflow_run_count_n" .Page.Paginater.Total}} -
- -
- {{ctx.Locale.Tr "actions.runs.actor"}} - {{svg "octicon-triangle-down" 14 "dropdown icon"}} -
-
- {{svg "octicon-search"}} - -
-
- {{ctx.Locale.Tr "actions.runs.actors_no_select"}} - - {{range .Actors}} - - {{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}} +
+ {{ctx.Locale.TrN .Page.Paginater.Total "actions.runs.workflow_run_count_1" "actions.runs.workflow_run_count_n" .Page.Paginater.Total}} +
+ +
+ {{ctx.Locale.Tr "actions.runs.actor"}} + {{svg "octicon-triangle-down" 14 "dropdown icon"}} +
+
+ {{svg "octicon-search"}} + +
+
+ {{ctx.Locale.Tr "actions.runs.actors_no_select"}} - {{end}} -
-
- -
- {{ctx.Locale.Tr "actions.runs.status"}} - {{svg "octicon-triangle-down" 14 "dropdown icon"}} -
-
- {{svg "octicon-search"}} - + {{range .Actors}} + + {{ctx.AvatarUtils.Avatar . 20}} {{.GetDisplayName}} + + {{end}} +
+
+ +
+ {{ctx.Locale.Tr "actions.runs.status"}} + {{svg "octicon-triangle-down" 14 "dropdown icon"}} +
+
+ {{svg "octicon-search"}} + +
+ + {{ctx.Locale.Tr "actions.runs.status_no_select"}} + + {{range .StatusInfoList}} + + {{.DisplayedStatus}} + + {{end}}
- - {{ctx.Locale.Tr "actions.runs.status_no_select"}} - - {{range .StatusInfoList}} - - {{.DisplayedStatus}} - - {{end}}
-
- {{if .AllowDisableOrEnableWorkflow}} + {{if .AllowDisableOrEnableWorkflow}} - {{end}} + {{end}}
diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index 5c64a0f5eec..0f35fa014e5 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -11,9 +11,13 @@ {{template "repo/actions/status" (dict "status" $run.Status.String)}}
- - {{or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}} - + + {{if $run.Title}} + {{ctx.RenderUtils.RenderCommitMessageLinkSubject $run.Title $run.Link $.Repository}} + {{else}} + {{ctx.Locale.Tr "actions.runs.empty_commit_message"}} + {{end}} +
{{if not $.CurWorkflow}}{{$run.WorkflowID}} {{end}}#{{$run.Index}}: diff --git a/templates/repo/actions/view.tmpl b/templates/repo/actions/view.tmpl index 1eb84a9b937..3f879e0e5a9 100644 --- a/templates/repo/actions/view.tmpl +++ b/templates/repo/actions/view.tmpl @@ -3,9 +3,8 @@
{{template "repo/header" .}} {{template "repo/actions/view_component" (dict - "RunID" .RunID "JobID" .JobID - "ActionsURL" .ActionsURL + "ActionsViewURL" .ActionsViewURL )}}
diff --git a/templates/repo/actions/view_component.tmpl b/templates/repo/actions/view_component.tmpl index 405e9cfb4b1..67926276c0c 100644 --- a/templates/repo/actions/view_component.tmpl +++ b/templates/repo/actions/view_component.tmpl @@ -1,17 +1,18 @@ -
- {{ctx.Locale.Tr "actions.workflow.has_workflow_dispatch"}} - +{{/* "z-index" is used to maintain continuous attached styling and keep the colored border-bottom visible (pre-existing fomantic issue with negative margins) */}} +
+ {{ctx.Locale.Tr "actions.workflow.has_workflow_dispatch"}} +
{{/*make the button have correct hovered color */}} + +
-
+
- + {{svg "octicon-git-branch" 14}}
{{index .Branches 0}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}} @@ -45,12 +51,8 @@
-
- -
- {{template "repo/actions/workflow_dispatch_inputs" .}} -
+ {{template "repo/actions/workflow_dispatch_inputs" .}}
diff --git a/templates/repo/actions/workflow_dispatch_inputs.tmpl b/templates/repo/actions/workflow_dispatch_inputs.tmpl index 085fd553de5..47caa9bac42 100644 --- a/templates/repo/actions/workflow_dispatch_inputs.tmpl +++ b/templates/repo/actions/workflow_dispatch_inputs.tmpl @@ -1,3 +1,4 @@ +
{{if not .WorkflowDispatchConfig}}
{{/* using "ui message" in "ui form" needs to force to display */}} {{if not .CurWorkflowExists}} @@ -11,14 +12,12 @@
{{if eq .Type "choice"}} - {{/* htmx won't initialize the fomantic dropdown, so it is a standard "select" input */}} {{else if eq .Type "boolean"}} - {{/* htmx doesn't trigger our JS code to attach fomantic label to checkbox, so here we use standard checkbox */}}
{{end}} {{end}} +
diff --git a/templates/repo/activity.tmpl b/templates/repo/activity.tmpl index a19fb662616..a9883b3a4fd 100644 --- a/templates/repo/activity.tmpl +++ b/templates/repo/activity.tmpl @@ -5,7 +5,7 @@
{{template "repo/navbar" .}}
-
+
{{if .PageIsPulse}}{{template "repo/pulse" .}}{{end}} {{if .PageIsContributors}}{{template "repo/contributors" .}}{{end}} {{if .PageIsCodeFrequency}}{{template "repo/code_frequency" .}}{{end}} diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 51052d93594..8bdefa5d43e 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -11,7 +11,7 @@ {{end}} {{end}}
-

+

{{template "repo/file_info" .}}
@@ -45,10 +45,8 @@
{{$row.Avatar}}
-
- - {{$row.CommitMessage}} - +
+ {{ctx.RenderUtils.RenderCommitMessageLinkSubject $row.CommitMessage $row.CommitURL $.Repository}}
{{$row.CommitSince}} diff --git a/templates/repo/branch/list.tmpl b/templates/repo/branch/list.tmpl index 5ae33935758..40aefe5b113 100644 --- a/templates/repo/branch/list.tmpl +++ b/templates/repo/branch/list.tmpl @@ -70,7 +70,7 @@
{{end}} -

+

{{ctx.Locale.Tr "repo.branches"}}
diff --git a/templates/repo/commit_load_branches_and_tags.tmpl b/templates/repo/commit_load_branches_and_tags.tmpl index ecb210c575c..18576f871fb 100644 --- a/templates/repo/commit_load_branches_and_tags.tmpl +++ b/templates/repo/commit_load_branches_and_tags.tmpl @@ -1,14 +1,14 @@ {{if not .PageIsWiki}}
{{if .MergedPRIssueNumber}} - {{$prLink := HTMLFormat `#%d` $.RepoLink $.MergedPRIssueNumber $.MergedPRIssueNumber}} + {{$prLink := HTMLFormat `#%d` $.RepoLink $.MergedPRIssueNumber $.MergedPRIssueNumber}}
{{ctx.Locale.Tr "repo.commit.merged_in_pr" $prLink}}
{{end}}
diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 179f6018d3d..8451a6f3cf1 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -195,7 +195,7 @@ {{DateUtils.TimeSince .NoteCommit.Author.When}}
-
{{.NoteRendered | SanitizeHTML}}
+
{{.NoteRendered}}
{{end}} diff --git a/templates/repo/commits_list.tmpl b/templates/repo/commits_list.tmpl index a0722307a70..d520c0fbcf7 100644 --- a/templates/repo/commits_list.tmpl +++ b/templates/repo/commits_list.tmpl @@ -30,7 +30,7 @@ {{$commitBaseLink = printf "%s/wiki/commit" $commitRepoLink}} {{else if $.PageIsPullCommits}} {{$commitBaseLink = printf "%s/pulls/%d/commits" $commitRepoLink $.Issue.Index}} - {{else if $.Reponame}} + {{else}} {{$commitBaseLink = printf "%s/commit" $commitRepoLink}} {{end}} {{template "repo/commit_sign_badge" dict "Commit" . "CommitBaseLink" $commitBaseLink "CommitSignVerification" .Verification}} diff --git a/templates/repo/commits_table.tmpl b/templates/repo/commits_table.tmpl index 8f6e6e01692..56a4867ff4b 100644 --- a/templates/repo/commits_table.tmpl +++ b/templates/repo/commits_table.tmpl @@ -1,4 +1,4 @@ -

+

{{if or .PageIsCommits (gt .CommitCount 0)}} {{.CommitCount}} {{ctx.Locale.Tr "repo.commits.commits"}} diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 390e41ec340..ccb9e80f28b 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -8,7 +8,7 @@ {{svg "octicon-sidebar-collapse" 20 "icon tw-hidden"}} {{svg "octicon-sidebar-expand" 20 "icon tw-hidden"}} - {{end}} @@ -211,7 +211,7 @@ {{if .Diff.IsIncomplete}}
-

+

{{ctx.Locale.Tr "repo.diff.too_many_files"}} {{ctx.Locale.Tr "repo.diff.show_more"}}

diff --git a/templates/repo/diff/compare.tmpl b/templates/repo/diff/compare.tmpl index 87c783f4460..afd44f26a47 100644 --- a/templates/repo/diff/compare.tmpl +++ b/templates/repo/diff/compare.tmpl @@ -13,22 +13,16 @@ {{ctx.Locale.Tr "action.compare_commits_general"}} {{end}}

- {{$BaseCompareName := $.BaseName -}} - {{- $HeadCompareName := $.HeadRepo.OwnerName -}} - {{- if and (eq $.BaseName $.HeadRepo.OwnerName) (ne $.Repository.Name $.HeadRepo.Name) -}} - {{- $HeadCompareName = printf "%s/%s" $.HeadRepo.OwnerName $.HeadRepo.Name -}} - {{- end -}} - {{- $OwnForkCompareName := "" -}} - {{- if .OwnForkRepo -}} - {{- $OwnForkCompareName = .OwnForkRepo.OwnerName -}} - {{- end -}} - {{- $RootRepoCompareName := "" -}} - {{- if .RootRepo -}} - {{- $RootRepoCompareName = .RootRepo.OwnerName -}} - {{- if eq $.HeadRepo.OwnerName .RootRepo.OwnerName -}} - {{- $HeadCompareName = printf "%s/%s" $.HeadRepo.OwnerName $.HeadRepo.Name -}} - {{- end -}} - {{- end -}} + {{$BaseCompareName := $.Repository.FullName -}} + {{$HeadCompareName := $.HeadRepo.FullName -}} + {{$OwnForkCompareName := "" -}} + {{if $.OwnForkRepo -}} + {{$OwnForkCompareName = $.OwnForkRepo.FullName -}} + {{end -}} + {{$RootRepoCompareName := "" -}} + {{if $.RootRepo -}} + {{$RootRepoCompareName = $.RootRepo.FullName -}} + {{end -}}
{{svg "octicon-git-compare"}} diff --git a/templates/repo/diff/image_diff.tmpl b/templates/repo/diff/image_diff.tmpl index 7557129c648..5a9dbc90674 100644 --- a/templates/repo/diff/image_diff.tmpl +++ b/templates/repo/diff/image_diff.tmpl @@ -7,8 +7,8 @@ data-mime-before="{{.sniffedTypeBase.GetMimeType}}" data-mime-after="{{.sniffedTypeHead.GetMimeType}}" > - -
+ +
{{ctx.Locale.Tr "repo.diff.image.side_by_side"}} {{if and .blobBase .blobHead}} {{ctx.Locale.Tr "repo.diff.image.swipe"}} diff --git a/templates/repo/editor/edit.tmpl b/templates/repo/editor/edit.tmpl index bb7cb705b38..0acd7bfd717 100644 --- a/templates/repo/editor/edit.tmpl +++ b/templates/repo/editor/edit.tmpl @@ -18,12 +18,12 @@ {{if not .NotEditableReason}}
-
-
+
+
{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}} - {{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}} + {{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}} {{if not .IsNewFile}} - {{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}} + {{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}} {{end}}
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}} @@ -37,10 +37,10 @@
- {{ctx.Locale.Tr "loading"}} +
-
+
diff --git a/templates/repo/editor/patch.tmpl b/templates/repo/editor/patch.tmpl index 78fdbfa0160..124ab0039e5 100644 --- a/templates/repo/editor/patch.tmpl +++ b/templates/repo/editor/patch.tmpl @@ -21,15 +21,15 @@
-
-
- {{svg "octicon-code" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.editor.new_patch"}} +
+
+ {{svg "octicon-code" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.editor.new_patch"}}
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}
-
+
- + {{else if eq .CaptchaType "hcaptcha"}}
- + {{else if eq .CaptchaType "mcaptcha"}}
@@ -25,5 +25,5 @@
- + {{end}}{{end}} diff --git a/templates/user/auth/external_auth_methods.tmpl b/templates/user/auth/external_auth_methods.tmpl index c23cab65657..119be95d8bb 100644 --- a/templates/user/auth/external_auth_methods.tmpl +++ b/templates/user/auth/external_auth_methods.tmpl @@ -1,7 +1,6 @@
{{range $provider := .OAuth2Providers}} - {{/* use QueryEscape for consistent with frontend urlQueryEscape, it is right for a path component */}} - + {{$provider.IconHTML 24}} {{ctx.Locale.Tr "sign_in_with_provider" $provider.DisplayName}} {{end}} diff --git a/templates/user/auth/link_account.tmpl b/templates/user/auth/link_account.tmpl index d244ce38c2e..3187d2a5867 100644 --- a/templates/user/auth/link_account.tmpl +++ b/templates/user/auth/link_account.tmpl @@ -1,16 +1,14 @@ {{template "base/head" .}}
-
+
{{if not .AllowOnlyInternalRegistration}} - + {{ctx.Locale.Tr "auth.oauth_signup_tab"}} {{end}} - + {{ctx.Locale.Tr "auth.oauth_signin_tab"}}
diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl index de93e6a6f0d..99f7f91bfdb 100644 --- a/templates/user/dashboard/feeds.tmpl +++ b/templates/user/dashboard/feeds.tmpl @@ -1,4 +1,4 @@ -
+
{{range .Feeds}}
@@ -107,7 +107,7 @@ {{else if .GetOpType.InActions "create_pull_request"}} {{index .GetIssueInfos 1 | ctx.RenderUtils.RenderIssueSimpleTitle}} {{else if .GetOpType.InActions "comment_issue" "approve_pull_request" "reject_pull_request" "comment_pull"}} - {{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}} + {{(.GetIssueTitle ctx) | ctx.RenderUtils.RenderIssueSimpleTitle}} {{$comment := index .GetIssueInfos 1}} {{if $comment}}
{{ctx.RenderUtils.MarkdownToHtml $comment}}
diff --git a/templates/user/dashboard/repolist.tmpl b/templates/user/dashboard/repolist.tmpl index 8b0fcbb401c..105f9566b7a 100644 --- a/templates/user/dashboard/repolist.tmpl +++ b/templates/user/dashboard/repolist.tmpl @@ -1,4 +1,4 @@ -
<script></script>
`, respSub.Body.String()) + assert.Equal(t, + ``+ + ``+ + `
<script></script>
`, + respSub.Body.String(), + ) }) }) @@ -129,9 +134,14 @@ func TestExternalMarkupRenderer(t *testing.T) { }) t.Run("HTMLContentWithExternalRenderIframeHelper", func(t *testing.T) { - req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer") + req := NewRequest(t, "GET", "/user2/repo1/render/branch/master/html.no-sanitizer?a=1%2f2") respSub := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, ``, respSub.Body.String()) + assert.Equal(t, + ``+ + ``+ + ``, + respSub.Body.String(), + ) assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy")) }) }) diff --git a/tests/integration/migrate_test.go b/tests/integration/migrate_test.go index 8c8f053ede3..613c5b9acac 100644 --- a/tests/integration/migrate_test.go +++ b/tests/integration/migrate_test.go @@ -6,13 +6,16 @@ package integration import ( "fmt" "net/http" + "net/http/cgi" + "net/http/httptest" "net/url" "os" + "os/exec" "path/filepath" + "runtime" "strconv" "strings" "testing" - "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -22,7 +25,6 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/gitrepo" - "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/services/migrations" @@ -120,24 +122,133 @@ func Test_UpdateCommentsMigrationsByType(t *testing.T) { assert.NoError(t, err) } +// setupGiteaMockServer creates a mock HTTP server that replays API responses from fixture files. +// If a GITEA_TOKEN environment variable is set, the mock server proxies requests to the live +// gitea.com instance and saves the responses as fixture files for future test runs. +// Example: GITEA_TOKEN=your_token go test -run Test_MigrateFromGiteaToGitea +func setupGiteaMockServer(t *testing.T) *httptest.Server { + t.Helper() + + giteaToken := os.Getenv("GITEA_TOKEN") + liveMode := giteaToken != "" + + // fast-import data creates deterministic commits (fixed author/committer/timestamps), + // so the resulting SHAs are always the same across runs. + fastImportData := `commit refs/heads/master +mark :1 +author Test 1000000000 +0000 +committer Test 1000000000 +0000 +data 8 +initial + +commit refs/heads/master +mark :2 +author Test 1000000001 +0000 +committer Test 1000000001 +0000 +data 7 +second + +from :1 + +commit refs/heads/6543-patch-1 +mark :3 +author Test 1000000002 +0000 +committer Test 1000000002 +0000 +data 6 +patch + +from :2 + +reset refs/tags/V1 +from :1 + +reset refs/tags/v2-rc1 +from :2 + +done +` + // Fork adds one extra branch for the PR head (from master = 873987e) + forkExtraData := `commit refs/heads/add-xkcd-2199 +author Test 1000000003 +0000 +committer Test 1000000003 +0000 +data 5 +xkcd + +from 873987ea3e99c206bb0841266845098ee74d4ce9 + +done +` + fastImport := func(dir, data string) { + cmd := exec.Command("git", "-C", dir, "fast-import", "--date-format=raw", "--done") + cmd.Stdin = strings.NewReader(data) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "fast-import failed: %s", out) + } + + repoDir := t.TempDir() + out, err := exec.Command("git", "init", "--bare", repoDir).CombinedOutput() + require.NoError(t, err, "git init failed: %s", out) + fastImport(repoDir, fastImportData) + + forkDir := t.TempDir() + out, err = exec.Command("git", "clone", "--bare", repoDir, forkDir).CombinedOutput() + require.NoError(t, err, "git clone failed: %s", out) + fastImport(forkDir, forkExtraData) + + // Find git-http-backend + execPathBytes, err := exec.Command("git", "--exec-path").Output() + require.NoError(t, err) + httpBackend := filepath.Join(strings.TrimSpace(string(execPathBytes)), "git-http-backend") + + _, callerFile, _, _ := runtime.Caller(0) + fixtureDir := filepath.Join(filepath.Dir(callerFile), "_mock_data/Test_MigrateFromGiteaToGitea") + return unittest.NewMockWebServer(t, "https://gitea.com", + fixtureDir, liveMode, + unittest.MockServerOptions{ + Routes: func(mux *http.ServeMux) { + mux.HandleFunc("/gitea/test_repo.wiki.git/", func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "wiki not found", http.StatusNotFound) + }) + gitHandler := func(dir, prefix string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + handler := &cgi.Handler{ + Path: httpBackend, + Dir: dir, + Env: []string{ + "GIT_PROJECT_ROOT=" + filepath.Dir(dir), + "GIT_HTTP_EXPORT_ALL=1", + }, + } + r.URL.Path = "/" + filepath.Base(dir) + strings.TrimPrefix(r.URL.Path, prefix) + handler.ServeHTTP(w, r) + } + } + mux.HandleFunc("/gitea/test_repo.git/", gitHandler(repoDir, "/gitea/test_repo.git")) + mux.HandleFunc("/6543-forks/test_repo.git/", gitHandler(forkDir, "/6543-forks/test_repo.git")) + }, + }, + ) +} + func Test_MigrateFromGiteaToGitea(t *testing.T) { defer tests.PrepareTestEnv(t)() + AllowLocalNetworks := setting.Migrations.AllowLocalNetworks + setting.Migrations.AllowLocalNetworks = true + defer func() { + setting.Migrations.AllowLocalNetworks = AllowLocalNetworks + migrations.Init() + }() + require.NoError(t, migrations.Init()) + + mockServer := setupGiteaMockServer(t) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) session := loginUser(t, owner.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll) - resp, err := httplib.NewRequest("https://gitea.com/gitea/test_repo.git", "GET").SetReadWriteTimeout(5 * time.Second).Response() - if err != nil || resp.StatusCode != http.StatusOK { - if resp != nil { - resp.Body.Close() - } - t.Skipf("Can't reach https://gitea.com, skipping %s", t.Name()) - } - resp.Body.Close() - - repoName := fmt.Sprintf("gitea-to-gitea-%d", time.Now().UnixNano()) - cloneAddr := "https://gitea.com/gitea/test_repo.git" + repoName := "migrated-from-mock-gitea" + cloneAddr := mockServer.URL + "/gitea/test_repo.git" req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", &structs.MigrateRepoOptions{ CloneAddr: cloneAddr, diff --git a/tests/integration/migration-test/migration_test.go b/tests/integration/migration-test/migration_test.go index 2e25afb43c0..c1acdb999b1 100644 --- a/tests/integration/migration-test/migration_test.go +++ b/tests/integration/migration-test/migration_test.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/testlogger" "code.gitea.io/gitea/modules/util" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "xorm.io/xorm" @@ -36,10 +37,10 @@ var currentEngine *xorm.Engine func initMigrationTest(t *testing.T) func() { testlogger.Init() - unittest.InitSettingsForTesting() + setting.SetupGiteaTestEnv() assert.NotEmpty(t, setting.RepoRootPath) - assert.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath)) + assert.NoError(t, unittest.SyncDirs(filepath.Join(setting.GetGiteaTestSourceRoot(), "tests/gitea-repositories-meta"), setting.RepoRootPath)) assert.NoError(t, git.InitFull()) setting.LoadDBSetting() setting.InitLoggersForTest() diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index a1c9511648c..b61f887d36f 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -23,27 +23,71 @@ import ( "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/oauth2_provider" "code.gitea.io/gitea/tests" + "github.com/PuerkitoBio/goquery" "github.com/markbates/goth" "github.com/markbates/goth/gothic" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestOAuth2Provider(t *testing.T) { +func testOAuth2PrepareTestCode(t *testing.T) { + require.NoError(t, db.TruncateBeans(t.Context(), &auth_model.OAuth2AuthorizationCode{})) + err := db.Insert(t.Context(), &auth_model.OAuth2AuthorizationCode{ + GrantID: 1, + Code: "authcode", + CodeChallenge: "CjvyTLSdR47G5zYenDA-eDWW4lRrO8yvjcWwbD_deOg", // Code Verifier: N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt + CodeChallengeMethod: "S256", + RedirectURI: "https://example.com", + ValidUntil: timeutil.TimeStampNow() + 86400, + }, &auth_model.OAuth2AuthorizationCode{ + GrantID: 4, + Code: "authcodepublic", + CodeChallenge: "CjvyTLSdR47G5zYenDA-eDWW4lRrO8yvjcWwbD_deOg", //# Code Verifier: N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt + CodeChallengeMethod: "S256", + RedirectURI: "http://127.0.0.1/", + ValidUntil: timeutil.TimeStampNow() + 86400, + }) + require.NoError(t, err) +} + +func TestOAuth2(t *testing.T) { defer tests.PrepareTestEnv(t)() - t.Run("AuthorizeNoClientID", testAuthorizeNoClientID) - t.Run("AuthorizeUnregisteredRedirect", testAuthorizeUnregisteredRedirect) - t.Run("AuthorizeUnsupportedResponseType", testAuthorizeUnsupportedResponseType) - t.Run("AuthorizeUnsupportedCodeChallengeMethod", testAuthorizeUnsupportedCodeChallengeMethod) - t.Run("AuthorizeLoginRedirect", testAuthorizeLoginRedirect) - - t.Run("OAuth2WellKnown", testOAuth2WellKnown) + t.Run("Provider", func(t *testing.T) { + t.Run("AuthorizeNoClientID", testAuthorizeNoClientID) + t.Run("AuthorizeUnregisteredRedirect", testAuthorizeUnregisteredRedirect) + t.Run("AuthorizeUnsupportedResponseType", testAuthorizeUnsupportedResponseType) + t.Run("AuthorizeUnsupportedCodeChallengeMethod", testAuthorizeUnsupportedCodeChallengeMethod) + t.Run("AuthorizeLoginRedirect", testAuthorizeLoginRedirect) + t.Run("AuthorizeShow", testAuthorizeShow) + t.Run("AuthorizeGrantS256RequiresVerifier", testAuthorizeGrantS256RequiresVerifier) + t.Run("AuthorizeRedirectWithExistingGrant", testAuthorizeRedirectWithExistingGrant) + t.Run("AuthorizePKCERequiredForPublicClient", testAuthorizePKCERequiredForPublicClient) + t.Run("AccessTokenExchange", testAccessTokenExchange) + t.Run("AccessTokenExchangeWithPublicClient", testAccessTokenExchangeWithPublicClient) + t.Run("AccessTokenExchangeJSON", testAccessTokenExchangeJSON) + t.Run("AccessTokenExchangeWithoutPKCE", testAccessTokenExchangeWithoutPKCE) + t.Run("AccessTokenExchangeWithInvalidCredentials", testAccessTokenExchangeWithInvalidCredentials) + t.Run("AccessTokenExchangeWithBasicAuth", testAccessTokenExchangeWithBasicAuth) + t.Run("RefreshTokenInvalidation", testRefreshTokenInvalidation) + t.Run("OAuthIntrospection", testOAuthIntrospection) + t.Run("OAuthGrantScopesReadUserFailRepos", testOAuthGrantScopesReadUserFailRepos) + t.Run("OAuthGrantScopesReadRepositoryFailOrganization", testOAuthGrantScopesReadRepositoryFailOrganization) + t.Run("OAuthGrantScopesClaimPublicOnlyGroups", testOAuthGrantScopesClaimPublicOnlyGroups) + t.Run("OAuthGrantScopesClaimAllGroups", testOAuthGrantScopesClaimAllGroups) + t.Run("OAuth2WellKnown", testOAuth2WellKnown) + }) + t.Run("Client", func(t *testing.T) { + t.Run("OAuthSourceSpecialChars", testOAuthSourceSpecialChars) + t.Run("SignInOauthCallbackSyncSSHKeys", testSignInOauthCallbackSyncSSHKeys) + }) + // TODO: move more tests as sub-tests here, avoid unnecessary PrepareTestEnv } func testAuthorizeNoClientID(t *testing.T) { @@ -61,7 +105,7 @@ func testAuthorizeUnregisteredRedirect(t *testing.T) { } func testAuthorizeUnsupportedResponseType(t *testing.T) { - req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=UNEXPECTED&state=thestate") + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https://example.com&response_type=UNEXPECTED&state=thestate") ctx := loginUser(t, "user1") resp := ctx.MakeRequest(t, req, http.StatusSeeOther) u, err := resp.Result().Location() @@ -71,7 +115,7 @@ func testAuthorizeUnsupportedResponseType(t *testing.T) { } func testAuthorizeUnsupportedCodeChallengeMethod(t *testing.T) { - req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate&code_challenge_method=UNEXPECTED") + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https://example.com&response_type=code&state=thestate&code_challenge_method=UNEXPECTED") ctx := loginUser(t, "user1") resp := ctx.MakeRequest(t, req, http.StatusSeeOther) u, err := resp.Result().Location() @@ -85,9 +129,8 @@ func testAuthorizeLoginRedirect(t *testing.T) { assert.Contains(t, MakeRequest(t, req, http.StatusSeeOther).Body.String(), "/user/login") } -func TestAuthorizeShow(t *testing.T) { - defer tests.PrepareTestEnv(t)() - req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate") +func testAuthorizeShow(t *testing.T) { + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https://example.com&response_type=code&state=thestate") ctx := loginUser(t, "user4") resp := ctx.MakeRequest(t, req, http.StatusOK) @@ -95,11 +138,10 @@ func TestAuthorizeShow(t *testing.T) { AssertHTMLElement(t, htmlDoc, "#authorize-app", true) } -func TestAuthorizeGrantS256RequiresVerifier(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAuthorizeGrantS256RequiresVerifier(t *testing.T) { ctx := loginUser(t, "user4") codeChallenge := "CjvyTLSdR47G5zYenDA-eDWW4lRrO8yvjcWwbD_deOg" - req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate&code_challenge_method=S256&code_challenge="+url.QueryEscape(codeChallenge)) + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https://example.com&response_type=code&state=thestate&code_challenge_method=S256&code_challenge="+url.QueryEscape(codeChallenge)) resp := ctx.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) @@ -110,7 +152,7 @@ func TestAuthorizeGrantS256RequiresVerifier(t *testing.T) { "state": "thestate", "scope": "", "nonce": "", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "granted": "true", }) grantResp := ctx.MakeRequest(t, grantReq, http.StatusSeeOther) @@ -123,7 +165,7 @@ func TestAuthorizeGrantS256RequiresVerifier(t *testing.T) { "grant_type": "authorization_code", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": code, }) accessResp := MakeRequest(t, accessReq, http.StatusBadRequest) @@ -133,9 +175,8 @@ func TestAuthorizeGrantS256RequiresVerifier(t *testing.T) { assert.Equal(t, "failed PKCE code challenge", parsedError.ErrorDescription) } -func TestAuthorizeRedirectWithExistingGrant(t *testing.T) { - defer tests.PrepareTestEnv(t)() - req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https%3A%2F%2Fexample.com%2Fxyzzy&response_type=code&state=thestate") +func testAuthorizeRedirectWithExistingGrant(t *testing.T) { + req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https://example.com/&response_type=code&state=thestate") ctx := loginUser(t, "user1") resp := ctx.MakeRequest(t, req, http.StatusSeeOther) u, err := resp.Result().Location() @@ -143,11 +184,11 @@ func TestAuthorizeRedirectWithExistingGrant(t *testing.T) { assert.Equal(t, "thestate", u.Query().Get("state")) assert.Greaterf(t, len(u.Query().Get("code")), 30, "authorization code '%s' should be longer then 30", u.Query().Get("code")) u.RawQuery = "" - assert.Equal(t, "https://example.com/xyzzy", u.String()) + assert.Equal(t, "https://example.com/", u.String()) } -func TestAuthorizePKCERequiredForPublicClient(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAuthorizePKCERequiredForPublicClient(t *testing.T) { + testOAuth2PrepareTestCode(t) req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=ce5a1322-42a7-11ed-b878-0242ac120002&redirect_uri=http%3A%2F%2F127.0.0.1&response_type=code&state=thestate") ctx := loginUser(t, "user1") resp := ctx.MakeRequest(t, req, http.StatusSeeOther) @@ -157,13 +198,13 @@ func TestAuthorizePKCERequiredForPublicClient(t *testing.T) { assert.Equal(t, "PKCE is required for public clients", u.Query().Get("error_description")) } -func TestAccessTokenExchange(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAccessTokenExchange(t *testing.T) { + testOAuth2PrepareTestCode(t) req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -181,8 +222,8 @@ func TestAccessTokenExchange(t *testing.T) { assert.Greater(t, len(parsed.RefreshToken), 10) } -func TestAccessTokenExchangeWithPublicClient(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAccessTokenExchangeWithPublicClient(t *testing.T) { + testOAuth2PrepareTestCode(t) req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", "client_id": "ce5a1322-42a7-11ed-b878-0242ac120002", @@ -204,13 +245,13 @@ func TestAccessTokenExchangeWithPublicClient(t *testing.T) { assert.Greater(t, len(parsed.RefreshToken), 10) } -func TestAccessTokenExchangeJSON(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAccessTokenExchangeJSON(t *testing.T) { + testOAuth2PrepareTestCode(t) req := NewRequestWithJSON(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -228,13 +269,13 @@ func TestAccessTokenExchangeJSON(t *testing.T) { assert.Greater(t, len(parsed.RefreshToken), 10) } -func TestAccessTokenExchangeWithoutPKCE(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAccessTokenExchangeWithoutPKCE(t *testing.T) { + testOAuth2PrepareTestCode(t) req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", }) resp := MakeRequest(t, req, http.StatusBadRequest) @@ -244,14 +285,14 @@ func TestAccessTokenExchangeWithoutPKCE(t *testing.T) { assert.Equal(t, "failed PKCE code challenge", parsedError.ErrorDescription) } -func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAccessTokenExchangeWithInvalidCredentials(t *testing.T) { + testOAuth2PrepareTestCode(t) // invalid client id req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", "client_id": "???", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -266,7 +307,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) { "grant_type": "authorization_code", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "???", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -296,7 +337,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) { "grant_type": "authorization_code", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "???", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -311,7 +352,7 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) { "grant_type": "???", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -322,11 +363,11 @@ func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) { assert.Equal(t, "Only refresh_token or authorization_code grant type is supported", parsedError.ErrorDescription) } -func TestAccessTokenExchangeWithBasicAuth(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testAccessTokenExchangeWithBasicAuth(t *testing.T) { + testOAuth2PrepareTestCode(t) req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -347,7 +388,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) { // use wrong client_secret req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -361,7 +402,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) { // missing header req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -374,7 +415,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) { // client_id inconsistent with Authorization header req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "client_id": "inconsistent", }) @@ -388,7 +429,7 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) { // client_secret inconsistent with Authorization header req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "client_secret": "inconsistent", }) @@ -400,13 +441,13 @@ func TestAccessTokenExchangeWithBasicAuth(t *testing.T) { assert.Equal(t, "client_secret in request body inconsistent with Authorization header", parsedError.ErrorDescription) } -func TestRefreshTokenInvalidation(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testRefreshTokenInvalidation(t *testing.T) { + testOAuth2PrepareTestCode(t) req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -428,7 +469,7 @@ func TestRefreshTokenInvalidation(t *testing.T) { "grant_type": "refresh_token", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", // omit secret - "redirect_uri": "a", + "redirect_uri": "https://example.com", "refresh_token": parsed.RefreshToken, }) resp = MakeRequest(t, req, http.StatusBadRequest) @@ -441,7 +482,7 @@ func TestRefreshTokenInvalidation(t *testing.T) { "grant_type": "refresh_token", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "refresh_token": "UNEXPECTED", }) resp = MakeRequest(t, req, http.StatusBadRequest) @@ -454,7 +495,7 @@ func TestRefreshTokenInvalidation(t *testing.T) { "grant_type": "refresh_token", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "refresh_token": parsed.RefreshToken, }) @@ -481,13 +522,13 @@ func TestRefreshTokenInvalidation(t *testing.T) { assert.Equal(t, "token was already used", parsedError.ErrorDescription) } -func TestOAuthIntrospection(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testOAuthIntrospection(t *testing.T) { + testOAuth2PrepareTestCode(t) req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{ "grant_type": "authorization_code", "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138", "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=", - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": "authcode", "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt", }) @@ -539,14 +580,12 @@ func TestOAuthIntrospection(t *testing.T) { assert.Contains(t, resp.Body.String(), "no valid authorization") } -func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testOAuthGrantScopesReadUserFailRepos(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) appBody := api.CreateOAuth2ApplicationOptions{ Name: "oauth-provider-scopes-test", RedirectURIs: []string{ - "a", + "https://example.com", }, ConfidentialClient: true, } @@ -555,8 +594,7 @@ func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) { AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusCreated) - var app *api.OAuth2Application - DecodeJSON(t, resp, &app) + app := DecodeJSON(t, resp, &api.OAuth2Application{}) grant := &auth_model.OAuth2Grant{ ApplicationID: app.ID, @@ -571,7 +609,7 @@ func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) { ctx := loginUser(t, user.Name) - authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=https://example.com&response_type=code&state=thestate", app.ClientID) authorizeReq := NewRequest(t, "GET", authorizeURL) authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) @@ -581,7 +619,7 @@ func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) { "grant_type": "authorization_code", "client_id": app.ClientID, "client_secret": app.ClientSecret, - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": authcode, }) accessTokenResp := ctx.MakeRequest(t, accessTokenReq, 200) @@ -620,14 +658,12 @@ func TestOAuth_GrantScopesReadUserFailRepos(t *testing.T) { assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s), required=[read:repository]") } -func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testOAuthGrantScopesReadRepositoryFailOrganization(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) appBody := api.CreateOAuth2ApplicationOptions{ Name: "oauth-provider-scopes-test", RedirectURIs: []string{ - "a", + "https://example.com", }, ConfidentialClient: true, } @@ -636,8 +672,7 @@ func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { AddBasicAuth(user.Name) resp := MakeRequest(t, req, http.StatusCreated) - var app *api.OAuth2Application - DecodeJSON(t, resp, &app) + app := DecodeJSON(t, resp, &api.OAuth2Application{}) grant := &auth_model.OAuth2Grant{ ApplicationID: app.ID, @@ -652,7 +687,7 @@ func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { ctx := loginUser(t, user.Name) - authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=https://example.com&response_type=code&state=thestate", app.ClientID) authorizeReq := NewRequest(t, "GET", authorizeURL) authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) @@ -661,7 +696,7 @@ func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { "grant_type": "authorization_code", "client_id": app.ClientID, "client_secret": app.ClientSecret, - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": authcode, }) accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK) @@ -759,15 +794,13 @@ func TestOAuth_GrantScopesReadRepositoryFailOrganization(t *testing.T) { assert.Contains(t, errorParsed.Message, "token does not have at least one of required scope(s), required=[read:user read:organization]") } -func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testOAuthGrantScopesClaimPublicOnlyGroups(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) appBody := api.CreateOAuth2ApplicationOptions{ Name: "oauth-provider-scopes-test", RedirectURIs: []string{ - "a", + "https://example.com", }, ConfidentialClient: true, } @@ -776,8 +809,7 @@ func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) { AddBasicAuth(user.Name) appResp := MakeRequest(t, appReq, http.StatusCreated) - var app *api.OAuth2Application - DecodeJSON(t, appResp, &app) + app := DecodeJSON(t, appResp, &api.OAuth2Application{}) grant := &auth_model.OAuth2Grant{ ApplicationID: app.ID, @@ -792,7 +824,7 @@ func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) { ctx := loginUser(t, user.Name) - authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=https://example.com&response_type=code&state=thestate", app.ClientID) authorizeReq := NewRequest(t, "GET", authorizeURL) authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) @@ -802,7 +834,7 @@ func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) { "grant_type": "authorization_code", "client_id": app.ClientID, "client_secret": app.ClientSecret, - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": authcode, }) accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK) @@ -860,15 +892,13 @@ func TestOAuth_GrantScopesClaimPublicOnlyGroups(t *testing.T) { } } -func TestOAuth_GrantScopesClaimAllGroups(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testOAuthGrantScopesClaimAllGroups(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"}) appBody := api.CreateOAuth2ApplicationOptions{ Name: "oauth-provider-scopes-test", RedirectURIs: []string{ - "a", + "https://example.com", }, ConfidentialClient: true, } @@ -877,8 +907,7 @@ func TestOAuth_GrantScopesClaimAllGroups(t *testing.T) { AddBasicAuth(user.Name) appResp := MakeRequest(t, appReq, http.StatusCreated) - var app *api.OAuth2Application - DecodeJSON(t, appResp, &app) + app := DecodeJSON(t, appResp, &api.OAuth2Application{}) grant := &auth_model.OAuth2Grant{ ApplicationID: app.ID, @@ -893,7 +922,7 @@ func TestOAuth_GrantScopesClaimAllGroups(t *testing.T) { ctx := loginUser(t, user.Name) - authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID) + authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=https://example.com&response_type=code&state=thestate", app.ClientID) authorizeReq := NewRequest(t, "GET", authorizeURL) authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther) @@ -903,7 +932,7 @@ func TestOAuth_GrantScopesClaimAllGroups(t *testing.T) { "grant_type": "authorization_code", "client_id": app.ClientID, "client_secret": app.ClientSecret, - "redirect_uri": "a", + "redirect_uri": "https://example.com", "code": authcode, }) accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK) @@ -963,8 +992,7 @@ func testOAuth2WellKnown(t *testing.T) { t.Run("WellKnown", func(t *testing.T) { req := NewRequest(t, "GET", urlOpenidConfiguration) resp := MakeRequest(t, req, http.StatusOK) - var respMap map[string]any - DecodeJSON(t, resp, &respMap) + respMap := DecodeJSON(t, resp, map[string]any{}) assert.Equal(t, "https://try.gitea.io", respMap["issuer"]) assert.Equal(t, "https://try.gitea.io/login/oauth/authorize", respMap["authorization_endpoint"]) assert.Equal(t, "https://try.gitea.io/login/oauth/access_token", respMap["token_endpoint"]) @@ -978,8 +1006,7 @@ func testOAuth2WellKnown(t *testing.T) { defer test.MockVariableValue(&setting.OAuth2.JWTClaimIssuer, "https://try.gitea.io/")() req := NewRequest(t, "GET", urlOpenidConfiguration) resp := MakeRequest(t, req, http.StatusOK) - var respMap map[string]any - DecodeJSON(t, resp, &respMap) + respMap := DecodeJSON(t, resp, map[string]any{}) assert.Equal(t, "https://try.gitea.io/", respMap["issuer"]) // has trailing by JWTClaimIssuer assert.Equal(t, "https://try.gitea.io/login/oauth/authorize", respMap["authorization_endpoint"]) }) @@ -999,9 +1026,7 @@ func addOAuth2Source(t *testing.T, authName string, cfg oauth2.Source) { require.NoError(t, err) } -func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func createOAuth2MockProvider() *httptest.Server { var mockServer *httptest.Server mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -1016,6 +1041,12 @@ func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) { http.NotFound(w, r) } })) + + return mockServer +} + +func testSignInOauthCallbackSyncSSHKeys(t *testing.T) { + mockServer := createOAuth2MockProvider() defer mockServer.Close() ctx := t.Context() @@ -1091,3 +1122,47 @@ func TestSignInOauthCallbackSyncSSHKeys(t *testing.T) { }) } } + +// Checks if an OAuth provider with spaces within the name does work, +// with the encoding of its names in the URL (PR#37327) +func testOAuthSourceSpecialChars(t *testing.T) { + mockServer := createOAuth2MockProvider() + defer mockServer.Close() + + addOAuth2Source(t, "test space", oauth2.Source{ + Provider: "openidConnect", + OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration", + }) + addOAuth2Source(t, "test+plus", oauth2.Source{ + Provider: "openidConnect", + OpenIDConnectAutoDiscoveryURL: mockServer.URL + "/.well-known/openid-configuration", + }) + + testOAuth2 := func(t *testing.T, uri string, statusCode int) { + req := NewRequest(t, "GET", uri) + resp := MakeRequest(t, req, statusCode) + if statusCode == http.StatusTemporaryRedirect { + assert.NotEmpty(t, resp.Header().Get("Location")) + } else { + assert.Empty(t, resp.Header().Get("Location")) + } + } + + req := MakeRequest(t, NewRequest(t, "GET", "/user/login"), http.StatusOK) + doc := NewHTMLParser(t, req.Body) + var oauth2Links []string + doc.Find(".external-login-link").Each(func(i int, s *goquery.Selection) { + oauth2Links = append(oauth2Links, s.AttrOr("href", "")) + }) + assert.Equal(t, []string{ + "/user/oauth2/test%20space", + "/user/oauth2/test+plus", + }, oauth2Links) + + testOAuth2(t, "/user/oauth2/test%20space", http.StatusTemporaryRedirect) + testOAuth2(t, "/user/oauth2/test+space", http.StatusNotFound) + + testOAuth2(t, "/user/oauth2/test+plus", http.StatusTemporaryRedirect) + testOAuth2(t, "/user/oauth2/test%2Bplus", http.StatusTemporaryRedirect) + testOAuth2(t, "/user/oauth2/test%20plus", http.StatusNotFound) +} diff --git a/tests/integration/org_test.go b/tests/integration/org_test.go index cedc0406ca8..e34bc60635c 100644 --- a/tests/integration/org_test.go +++ b/tests/integration/org_test.go @@ -16,9 +16,12 @@ import ( "code.gitea.io/gitea/models/unit" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" + "github.com/PuerkitoBio/goquery" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -31,6 +34,7 @@ func TestOrg(t *testing.T) { t.Run("OrgMembers", testOrgMembers) t.Run("OrgRestrictedUser", testOrgRestrictedUser) t.Run("TeamSearch", testTeamSearch) + t.Run("TeamsPage", testTeamsPage) t.Run("OrgSettings", testOrgSettings) } @@ -178,11 +182,9 @@ func testOrgRestrictedUser(t *testing.T) { req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), teamToCreate). AddTokenAuth(token) - var apiTeam api.Team - resp := adminSession.MakeRequest(t, req, http.StatusCreated) - DecodeJSON(t, resp, &apiTeam) - checkTeamResponse(t, "CreateTeam_codereader", &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, + apiTeam := DecodeJSON(t, resp, &api.Team{}) + checkTeamResponse(t, "CreateTeam_codereader", apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, "none", teamToCreate.Units, nil) checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories, "none", teamToCreate.Units, nil) @@ -205,12 +207,10 @@ func testTeamSearch(t *testing.T) { user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15}) org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) - var results TeamSearchResults - session := loginUser(t, user.Name) req := NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "_team") resp := session.MakeRequest(t, req, http.StatusOK) - DecodeJSON(t, resp, &results) + results := DecodeJSON(t, resp, &TeamSearchResults{}) assert.NotEmpty(t, results.Data) assert.Len(t, results.Data, 2) assert.Equal(t, "review_team", results.Data[0].Name) @@ -251,6 +251,67 @@ func testTeamSearch(t *testing.T) { }) } +func testTeamsPage(t *testing.T) { + // org17 has three teams in fixtures: Owners (id 5), test_team (id 8), review_team (id 9). + // user15 is in Owners; user20 is in review_team only; user5 is not a member. + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17}) + + listTeams := func(t *testing.T, session *TestSession, query string) []string { + req := NewRequestf(t, "GET", "/org/%s/teams%s", org.Name, query) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + sel := htmlDoc.doc.Find(".ui.top.attached.header strong") + names := make([]string, 0, sel.Length()) + sel.Each(func(_ int, s *goquery.Selection) { + names = append(names, s.Text()) + }) + return names + } + + // Owner sees all teams, "Owners" sorted first regardless of alphabetical order + ownerSession := loginUser(t, "user15") + assert.Equal(t, []string{"Owners", "review_team", "test_team"}, listTeams(t, ownerSession, "")) + + // Keyword filter narrows by name + assert.Equal(t, []string{"review_team"}, listTeams(t, ownerSession, "?q=review")) + + // Non-admin org member sees only the teams they belong to + memberSession := loginUser(t, "user20") + assert.Equal(t, []string{"review_team"}, listTeams(t, memberSession, "")) + + // Edit review_team so user20 gets full list + reviewTeam := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 9}) + req := NewRequestWithValues(t, "POST", fmt.Sprintf("/org/%s/teams/%s/edit", org.Name, reviewTeam.Name), map[string]string{ + "team_name": reviewTeam.Name, + "description": reviewTeam.Description, + "repo_access": "all", + "permission": "admin", + "unit_1": "1", + "unit_2": "1", + "unit_3": "1", + "unit_4": "1", + "unit_5": "1", + "unit_6": "1", + "unit_7": "1", + "unit_8": "1", + "unit_9": "1", + "unit_10": "1", + }) + ownerSession.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, []string{"Owners", "review_team", "test_team"}, listTeams(t, memberSession, "")) + + // Non-member is denied + nonMemberSession := loginUser(t, "user5") + req = NewRequestf(t, "GET", "/org/%s/teams", org.Name) + nonMemberSession.MakeRequest(t, req, http.StatusNotFound) + + t.Run("Pagination", func(t *testing.T) { + defer test.MockVariableValue(&setting.UI.MembersPagingNum, 2)() + assert.Len(t, listTeams(t, ownerSession, "?page=1"), 2) + assert.Equal(t, []string{"test_team"}, listTeams(t, ownerSession, "?page=2")) + }) +} + func testOrgSettings(t *testing.T) { session := loginUser(t, "user2") diff --git a/tests/integration/privateactivity_test.go b/tests/integration/privateactivity_test.go index 7a76d609ea0..5315042b090 100644 --- a/tests/integration/privateactivity_test.go +++ b/tests/integration/privateactivity_test.go @@ -117,8 +117,7 @@ func testPrivateActivityHelperHasHeatmapContentFromPublic(t *testing.T) bool { req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityTestUser) resp := MakeRequest(t, req, http.StatusOK) - var items []*activities_model.UserHeatmapData - DecodeJSON(t, resp, &items) + items := DecodeJSON(t, resp, []*activities_model.UserHeatmapData{}) return len(items) != 0 } @@ -130,8 +129,7 @@ func testPrivateActivityHelperHasHeatmapContentFromSession(t *testing.T, session AddTokenAuth(token) resp := session.MakeRequest(t, req, http.StatusOK) - var items []*activities_model.UserHeatmapData - DecodeJSON(t, resp, &items) + items := DecodeJSON(t, resp, []*activities_model.UserHeatmapData{}) return len(items) != 0 } diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go index 1e38322dbf4..ddf0743040d 100644 --- a/tests/integration/project_test.go +++ b/tests/integration/project_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "strconv" + "strings" "testing" issues_model "code.gitea.io/gitea/models/issues" @@ -89,6 +90,110 @@ func TestMoveRepoProjectColumns(t *testing.T) { assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID)) } +func TestUpdateIssueProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // fixture: issue 3 is in project 1 of repo user2/repo1, column "In Progress" (id=2) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + assert.EqualValues(t, 1, issue.RepoID) + + sess := loginUser(t, "user2") + + t.Run("MoveColumn", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "3", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusOK) + + pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 3}) + assert.EqualValues(t, 3, pi.ProjectColumnID) + }) + + t.Run("InvalidIssueID", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "0", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("WrongRepo", func(t *testing.T) { + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "6", + "id": "3", + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("WrongProject", func(t *testing.T) { + project2 := project_model.Project{ + Title: "second project on repo1", + RepoID: 1, + Type: project_model.TypeRepository, + TemplateType: project_model.TemplateTypeNone, + } + require.NoError(t, project_model.NewProject(t.Context(), &project2)) + require.NoError(t, project_model.NewColumn(t.Context(), &project_model.Column{ + Title: "other column", + ProjectID: project2.ID, + })) + columns, err := project2.GetColumns(t.Context()) + require.NoError(t, err) + require.NotEmpty(t, columns) + + req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{ + "issue_id": "1", + "id": strconv.FormatInt(columns[0].ID, 10), + }) + sess.MakeRequest(t, req, http.StatusNotFound) + }) +} + +func TestIssueSidebarProjectColumn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // fixture: issue 5 (index=4) is in project 1 of repo user2/repo1, column "Done" (id=3) + sess := loginUser(t, "user2") + + req := NewRequest(t, "GET", "/user2/repo1/issues/4") + resp := sess.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + cards := htmlDoc.Find(".sidebar-project-card") + assert.Equal(t, 1, cards.Length()) + + title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis") + assert.Contains(t, strings.TrimSpace(title.Text()), "First project") + + columnCombo := cards.Find(".sidebar-project-column-combo") + assert.Equal(t, 1, columnCombo.Length()) + + defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`) + assert.Equal(t, 1, defaultItem.Length()) + + inProgressItem := columnCombo.Find(`.menu .item[data-value="2"]`) + assert.Equal(t, 1, inProgressItem.Length()) + doneItem := columnCombo.Find(`.menu .item[data-value="3"]`) + assert.Equal(t, 1, doneItem.Length()) + + comboVal, exists := columnCombo.Find("input.combo-value").Attr("value") + assert.True(t, exists) + assert.Equal(t, "3", comboVal) + + req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{ + "id": "0", + }) + sess.MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", "/user2/repo1/issues/4") + resp = sess.MakeRequest(t, req, http.StatusOK) + htmlDoc = NewHTMLParser(t, resp.Body) + + cards = htmlDoc.Find(".sidebar-project-card") + assert.Equal(t, 0, cards.Length()) +} + // getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page. func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} { t.Helper() diff --git a/tests/integration/pull_commit_test.go b/tests/integration/pull_commit_test.go index 01b8ec1ff4f..702bddfcef5 100644 --- a/tests/integration/pull_commit_test.go +++ b/tests/integration/pull_commit_test.go @@ -21,11 +21,10 @@ func TestListPullCommits(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1/pulls/3/commits/list") resp := session.MakeRequest(t, req, http.StatusOK) - var pullCommitList struct { + pullCommitList := DecodeJSON(t, resp, &struct { Commits []pull_service.CommitInfo `json:"commits"` LastReviewCommitSha string `json:"last_review_commit_sha"` - } - DecodeJSON(t, resp, &pullCommitList) + }{}) require.Len(t, pullCommitList.Commits, 2) assert.Equal(t, "985f0301dba5e7b34be866819cd15ad3d8f508ee", pullCommitList.Commits[0].ID) diff --git a/tests/integration/pull_create_test.go b/tests/integration/pull_create_test.go index 2c17557eb07..fa10158a57c 100644 --- a/tests/integration/pull_create_test.go +++ b/tests/integration/pull_create_test.go @@ -189,7 +189,8 @@ func testDeleteRepository(t *testing.T, session *TestSession, ownerName, repoNam req := NewRequestWithValues(t, "POST", relURL+"?action=delete", map[string]string{ "repo_name": repoName, }) - session.MakeRequest(t, req, http.StatusSeeOther) + resp := session.MakeRequest(t, req, http.StatusOK) + assert.NotNil(t, test.ParseJSONRedirect(resp.Body.Bytes()).Redirect) } func TestPullBranchDelete(t *testing.T) { @@ -288,8 +289,7 @@ func TestCreatePullRequestFromNestedOrgForks(t *testing.T) { Readme: "Default", }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusCreated) - var baseRepo api.Repository - DecodeJSON(t, resp, &baseRepo) + baseRepo := DecodeJSON(t, resp, &api.Repository{}) assert.Equal(t, "main", baseRepo.DefaultBranch) forkIntoOrg := func(srcOrg, dstOrg string) api.Repository { @@ -297,13 +297,12 @@ func TestCreatePullRequestFromNestedOrgForks(t *testing.T) { Organization: new(dstOrg), }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusAccepted) - var forkRepo api.Repository - DecodeJSON(t, resp, &forkRepo) + forkRepo := DecodeJSON(t, resp, &api.Repository{}) assert.NotNil(t, forkRepo.Owner) if forkRepo.Owner != nil { assert.Equal(t, dstOrg, forkRepo.Owner.UserName) } - return forkRepo + return *forkRepo } forkIntoOrg(baseOrg, midForkOrg) @@ -326,8 +325,7 @@ func TestCreatePullRequestFromNestedOrgForks(t *testing.T) { } req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls", baseOrg, repoName), prPayload).AddTokenAuth(token) resp = MakeRequest(t, req, http.StatusCreated) - var pr api.PullRequest - DecodeJSON(t, resp, &pr) + pr := DecodeJSON(t, resp, &api.PullRequest{}) assert.Equal(t, prPayload["title"], pr.Title) if assert.NotNil(t, pr.Head) { assert.Equal(t, patchBranch, pr.Head.Ref) diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index 53709e6ff4b..3d24c4c3264 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -17,6 +17,7 @@ import ( "time" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" pull_model "code.gitea.io/gitea/models/pull" @@ -36,6 +37,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/automerge" "code.gitea.io/gitea/services/automergequeue" + "code.gitea.io/gitea/services/forms" pull_service "code.gitea.io/gitea/services/pull" repo_service "code.gitea.io/gitea/services/repository" commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" @@ -91,12 +93,25 @@ func testPullCleanUp(t *testing.T, session *TestSession, user, repo, pullnum str return resp } +func preparePullMergeWebhook(t *testing.T, repoID int64) { + require.NoError(t, db.TruncateBeans(t.Context(), &webhook.Webhook{}, &webhook.HookTask{})) + require.NoError(t, db.Insert(t.Context(), &webhook.Webhook{ + RepoID: repoID, + URL: "http://localhost/gitea-test-webhook-pull-merge", + ContentType: webhook.ContentTypeJSON, + Events: `{"push_only":true,"send_everything":false,"choose_events":false,"events":{"create":false,"push":true,"pull_request":false}}`, + IsActive: true, + })) +} + +func assertPullMergeWebhookTask(t *testing.T, repoID int64) { + hook := unittest.AssertExistsAndLoadBean(t, &webhook.Webhook{RepoID: repoID}) + unittest.AssertExistsAndLoadBean(t, &webhook.HookTask{HookID: hook.ID}) +} + func TestPullMerge(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { - hookTasks, err := webhook.HookTasks(t.Context(), 1, 1) // Retrieve previous hook number - assert.NoError(t, err) - hookTasksLenBefore := len(hookTasks) - + preparePullMergeWebhook(t, 1) session := loginUser(t, "user1") // FIXME: don't use admin user for testing testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") @@ -122,18 +137,13 @@ func TestPullMerge(t *testing.T) { assert.Equal(t, 4, repo.NumPulls) assert.Equal(t, 3, repo.NumOpenPulls) - hookTasks, err = webhook.HookTasks(t.Context(), 1, 1) - assert.NoError(t, err) - assert.Len(t, hookTasks, hookTasksLenBefore+1) + assertPullMergeWebhookTask(t, 1) }) } func TestPullRebase(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { - hookTasks, err := webhook.HookTasks(t.Context(), 1, 1) // Retrieve previous hook number - assert.NoError(t, err) - hookTasksLenBefore := len(hookTasks) - + preparePullMergeWebhook(t, 1) session := loginUser(t, "user1") // FIXME: don't use admin user for testing testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") @@ -159,18 +169,13 @@ func TestPullRebase(t *testing.T) { assert.Equal(t, 4, repo.NumPulls) assert.Equal(t, 3, repo.NumOpenPulls) - hookTasks, err = webhook.HookTasks(t.Context(), 1, 1) - assert.NoError(t, err) - assert.Len(t, hookTasks, hookTasksLenBefore+1) + assertPullMergeWebhookTask(t, 1) }) } func TestPullRebaseMerge(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { - hookTasks, err := webhook.HookTasks(t.Context(), 1, 1) // Retrieve previous hook number - assert.NoError(t, err) - hookTasksLenBefore := len(hookTasks) - + preparePullMergeWebhook(t, 1) session := loginUser(t, "user1") // FIXME: don't use admin user for testing testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") @@ -196,18 +201,13 @@ func TestPullRebaseMerge(t *testing.T) { assert.Equal(t, 4, repo.NumPulls) assert.Equal(t, 3, repo.NumOpenPulls) - hookTasks, err = webhook.HookTasks(t.Context(), 1, 1) - assert.NoError(t, err) - assert.Len(t, hookTasks, hookTasksLenBefore+1) + assertPullMergeWebhookTask(t, 1) }) } func TestPullSquash(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { - hookTasks, err := webhook.HookTasks(t.Context(), 1, 1) // Retrieve previous hook number - assert.NoError(t, err) - hookTasksLenBefore := len(hookTasks) - + preparePullMergeWebhook(t, 1) session := loginUser(t, "user1") // FIXME: don't use admin user for testing testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") @@ -222,18 +222,13 @@ func TestPullSquash(t *testing.T) { DeleteBranch: false, }) - hookTasks, err = webhook.HookTasks(t.Context(), 1, 1) - assert.NoError(t, err) - assert.Len(t, hookTasks, hookTasksLenBefore+1) + assertPullMergeWebhookTask(t, 1) }) } func TestPullSquashWithHeadCommitID(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { - hookTasks, err := webhook.HookTasks(t.Context(), 1, 1) // Retrieve previous hook number - assert.NoError(t, err) - hookTasksLenBefore := len(hookTasks) - + preparePullMergeWebhook(t, 1) session := loginUser(t, "user1") // FIXME: don't use admin user for testing testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n") @@ -266,9 +261,7 @@ func TestPullSquashWithHeadCommitID(t *testing.T) { assert.Equal(t, 4, repo.NumPulls) assert.Equal(t, 3, repo.NumOpenPulls) - hookTasks, err = webhook.HookTasks(t.Context(), 1, 1) - assert.NoError(t, err) - assert.Len(t, hookTasks, hookTasksLenBefore+1) + assertPullMergeWebhookTask(t, 1) }) } @@ -302,24 +295,19 @@ func TestPullCleanUpAfterMerge(t *testing.T) { // Check PR branch deletion resp = testPullCleanUp(t, session, elem[1], elem[2], elem[4]) - respJSON := struct { - Redirect string - }{} - DecodeJSON(t, resp, &respJSON) + respJSON := test.ParseJSONRedirect(resp.Body.Bytes()) + require.NotEmpty(t, respJSON.Redirect, "Redirected URL is not found") - assert.NotEmpty(t, respJSON.Redirect, "Redirected URL is not found") - - elem = strings.Split(respJSON.Redirect, "/") + elem = strings.Split(*respJSON.Redirect, "/") assert.Equal(t, "pulls", elem[3]) // Check branch deletion result - req := NewRequest(t, "GET", respJSON.Redirect) + req := NewRequest(t, "GET", *respJSON.Redirect) resp = session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - resultMsg := htmlDoc.doc.Find(".ui.message>p").Text() - - assert.Equal(t, "Branch \"user1/repo1:feature/test\" has been deleted.", resultMsg) + resultMsg := strings.TrimSpace(htmlDoc.doc.Find(".ui.message.flash-message").Text()) + assert.Equal(t, `Branch "user1/repo1:feature/test" has been deleted.`, resultMsg) }) } @@ -511,6 +499,92 @@ func TestFastForwardOnlyMerge(t *testing.T) { }) } +func TestFastForwardOnlyMergeWithRequiredSignedCommits(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user1") + testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") + testEditFileToNewBranch(t, session, "user1", "repo1", "master", "update", "README.md", "Hello, signed\n") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + createPRReq := NewRequestWithJSON(t, http.MethodPost, "/api/v1/repos/user1/repo1/pulls", &api.CreatePullRequestOption{ + Head: "update", + Base: "master", + Title: "ff-only merge under require-signed-commits", + }).AddTokenAuth(token) + session.MakeRequest(t, createPRReq, http.StatusCreated) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"}) + repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user1.ID, Name: "repo1"}) + pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ + HeadRepoID: repo1.ID, + BaseRepoID: repo1.ID, + HeadBranch: "update", + BaseBranch: "master", + }) + + // Enable require-signed-commits on master. + require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, &git_model.ProtectedBranch{ + RepoID: repo1.ID, + RuleName: "master", + RequireSignedCommits: true, + }, git_model.WhitelistOptions{})) + + prIndex := strconv.FormatInt(pr.Index, 10) + mergeURL := "/user1/repo1/pulls/" + prIndex + "/merge" + + notVerifiedMsg := translation.NewLocale("en-US").TrString("repo.pulls.require_signed_head_commits_unverified") + apiNotVerifiedMsg := pull_service.ErrHeadCommitsNotAllVerified.Error() + // Matches the unexported "wont sign: %s" format and nokey signingMode in + // services/asymkey/sign.go; the test config uses SIGNING_KEY = none. + const wontSignMsg = "wont sign: nokey" + + for _, style := range []repo_model.MergeStyle{repo_model.MergeStyleFastForwardOnly, repo_model.MergeStyleMerge} { + t.Run(string(style)+"/head-commits-unverified", func(t *testing.T) { + mergeReq := NewRequestWithValues(t, http.MethodPost, mergeURL, map[string]string{"do": string(style)}) + resp := session.MakeRequest(t, mergeReq, http.StatusBadRequest) + assert.Equal(t, notVerifiedMsg, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) + }) + } + + for _, style := range []repo_model.MergeStyle{repo_model.MergeStyleRebase, repo_model.MergeStyleRebaseMerge, repo_model.MergeStyleSquash} { + t.Run(string(style)+"/wont-sign", func(t *testing.T) { + mergeReq := NewRequestWithValues(t, http.MethodPost, mergeURL, map[string]string{"do": string(style)}) + resp := session.MakeRequest(t, mergeReq, http.StatusBadRequest) + assert.Equal(t, wontSignMsg, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) + }) + } + + // Admin force-merge must not bypass the unverified-head-commits check, since + // the pre-receive hook would reject the push regardless. + t.Run("fast-forward-only/admin-force-merge-does-not-bypass", func(t *testing.T) { + mergeReq := NewRequestWithValues(t, http.MethodPost, mergeURL, map[string]string{ + "do": string(repo_model.MergeStyleFastForwardOnly), + "force_merge": "true", + }) + resp := session.MakeRequest(t, mergeReq, http.StatusBadRequest) + assert.Equal(t, notVerifiedMsg, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage) + }) + + t.Run("api/fast-forward-only/head-commits-unverified", func(t *testing.T) { + apiReq := NewRequestWithJSON(t, http.MethodPost, + fmt.Sprintf("/api/v1/repos/user1/repo1/pulls/%s/merge", prIndex), + &forms.MergePullRequestForm{Do: string(repo_model.MergeStyleFastForwardOnly)}, + ).AddTokenAuth(token) + resp := session.MakeRequest(t, apiReq, http.StatusMethodNotAllowed) + apiBody := DecodeJSON(t, resp, &api.APIError{}) + assert.Equal(t, apiNotVerifiedMsg, apiBody.Message) + }) + + pb, err := git_model.GetFirstMatchProtectedBranchRule(t.Context(), repo1.ID, "master") + require.NoError(t, err) + require.NotNil(t, pb) + pb.RequireSignedCommits = false + require.NoError(t, git_model.UpdateProtectBranch(t.Context(), repo1, pb, git_model.WhitelistOptions{})) + + require.NoError(t, pull_service.Merge(t.Context(), pr, user1, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)) + }) +} + func TestCantFastForwardOnlyMergeDiverging(t *testing.T) { onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { session := loginUser(t, "user1") // FIXME: don't use admin user for testing @@ -681,8 +755,7 @@ func TestPullMergeIndexerNotifier(t *testing.T) { // search issues searchIssuesResp := session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) - var apiIssuesBefore []*api.Issue - DecodeJSON(t, searchIssuesResp, &apiIssuesBefore) + apiIssuesBefore := DecodeJSON(t, searchIssuesResp, []*api.Issue{}) assert.Empty(t, apiIssuesBefore) // merge the pull request @@ -704,11 +777,9 @@ func TestPullMergeIndexerNotifier(t *testing.T) { // search issues again searchIssuesResp = session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) - var apiIssuesAfter []*api.Issue - DecodeJSON(t, searchIssuesResp, &apiIssuesAfter) - if assert.Len(t, apiIssuesAfter, 1) { - assert.Equal(t, issue.ID, apiIssuesAfter[0].ID) - } + apiIssuesAfter := DecodeJSON(t, searchIssuesResp, []*api.Issue{}) + require.Len(t, apiIssuesAfter, 1) + assert.Equal(t, issue.ID, apiIssuesAfter[0].ID) }) } diff --git a/tests/integration/repo_merge_upstream_test.go b/tests/integration/repo_merge_upstream_test.go index fcc6078fcdc..e0e47e1a038 100644 --- a/tests/integration/repo_merge_upstream_test.go +++ b/tests/integration/repo_merge_upstream_test.go @@ -131,8 +131,7 @@ func TestRepoMergeUpstream(t *testing.T) { resp := MakeRequest(t, req, http.StatusOK) checkFileContent("fork-branch", "test-content-2") - var mergeResp api.MergeUpstreamResponse - DecodeJSON(t, resp, &mergeResp) + mergeResp := DecodeJSON(t, resp, &api.MergeUpstreamResponse{}) assert.Equal(t, "merge", mergeResp.MergeStyle) // after merge, there should be no "sync fork" button anymore @@ -160,8 +159,7 @@ func TestRepoMergeUpstream(t *testing.T) { }).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - var mergeResp api.MergeUpstreamResponse - DecodeJSON(t, resp, &mergeResp) + mergeResp := DecodeJSON(t, resp, &api.MergeUpstreamResponse{}) assert.Equal(t, "fast-forward", mergeResp.MergeStyle) // ff_only=true when fast-forward is not possible (should fail) diff --git a/tests/integration/repo_tag_test.go b/tests/integration/repo_tag_test.go index 93ed1632356..eef83a794e2 100644 --- a/tests/integration/repo_tag_test.go +++ b/tests/integration/repo_tag_test.go @@ -157,8 +157,7 @@ func TestRepushTag(t *testing.T) { // query the release by API and it should be a draft req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, "v2.0")).AddTokenAuth(token) resp := MakeRequest(t, req, http.StatusOK) - var respRelease *api.Release - DecodeJSON(t, resp, &respRelease) + respRelease := DecodeJSON(t, resp, &api.Release{}) assert.True(t, respRelease.IsDraft) // re-push the tag @@ -167,7 +166,7 @@ func TestRepushTag(t *testing.T) { // query the release by API and it should not be a draft req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, "v2.0")) resp = MakeRequest(t, req, http.StatusOK) - DecodeJSON(t, resp, &respRelease) + respRelease = DecodeJSON(t, resp, &api.Release{}) assert.False(t, respRelease.IsDraft) }) } diff --git a/tests/integration/repo_topic_test.go b/tests/integration/repo_topic_test.go index 7f9594b9fde..522a774c4d2 100644 --- a/tests/integration/repo_topic_test.go +++ b/tests/integration/repo_topic_test.go @@ -17,13 +17,12 @@ import ( func TestTopicSearch(t *testing.T) { defer tests.PrepareTestEnv(t)() searchURL, _ := url.Parse("/explore/topics/search") - var topics struct { - TopicNames []*api.TopicResponse `json:"topics"` - } // search all topics res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) - DecodeJSON(t, res, &topics) + topics := DecodeJSON(t, res, &struct { + TopicNames []*api.TopicResponse `json:"topics"` + }{}) assert.Len(t, topics.TopicNames, 6) assert.Equal(t, "6", res.Header().Get("x-total-count")) @@ -33,7 +32,9 @@ func TestTopicSearch(t *testing.T) { searchURL.RawQuery = query.Encode() res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) - DecodeJSON(t, res, &topics) + topics = DecodeJSON(t, res, &struct { + TopicNames []*api.TopicResponse `json:"topics"` + }{}) assert.Len(t, topics.TopicNames, 4) assert.Equal(t, "6", res.Header().Get("x-total-count")) @@ -43,7 +44,9 @@ func TestTopicSearch(t *testing.T) { searchURL.RawQuery = query.Encode() res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) - DecodeJSON(t, res, &topics) + topics = DecodeJSON(t, res, &struct { + TopicNames []*api.TopicResponse `json:"topics"` + }{}) assert.Len(t, topics.TopicNames, 2) assert.Equal(t, "6", res.Header().Get("x-total-count")) @@ -53,14 +56,18 @@ func TestTopicSearch(t *testing.T) { query.Add("q", "topic") searchURL.RawQuery = query.Encode() res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) - DecodeJSON(t, res, &topics) + topics = DecodeJSON(t, res, &struct { + TopicNames []*api.TopicResponse `json:"topics"` + }{}) assert.Len(t, topics.TopicNames, 2) topics.TopicNames = nil query.Set("q", "database") searchURL.RawQuery = query.Encode() res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) - DecodeJSON(t, res, &topics) + topics = DecodeJSON(t, res, &struct { + TopicNames []*api.TopicResponse `json:"topics"` + }{}) if assert.Len(t, topics.TopicNames, 1) { assert.EqualValues(t, 2, topics.TopicNames[0].ID) assert.Equal(t, "database", topics.TopicNames[0].Name) diff --git a/tests/integration/repo_visibility_test.go b/tests/integration/repo_visibility_test.go index e7ad8ddd876..f9a2ab83695 100644 --- a/tests/integration/repo_visibility_test.go +++ b/tests/integration/repo_visibility_test.go @@ -39,7 +39,7 @@ func TestRepositoryVisibilityChange(t *testing.T) { "confirm_repo_name": "user2/repo1", }) resp = session.MakeRequest(t, req, http.StatusOK) - assert.NotEmpty(t, test.ParseJSONRedirect(resp.Body.Bytes()).Redirect) + assert.NotNil(t, test.ParseJSONRedirect(resp.Body.Bytes()).Redirect) repo1 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) assert.True(t, repo1.IsPrivate) @@ -51,7 +51,7 @@ func TestRepositoryVisibilityChange(t *testing.T) { "private": "false", }) resp := session.MakeRequest(t, req, http.StatusOK) - assert.NotEmpty(t, test.ParseJSONRedirect(resp.Body.Bytes()).Redirect) + assert.NotNil(t, test.ParseJSONRedirect(resp.Body.Bytes()).Redirect) repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2}) assert.False(t, repo2.IsPrivate) diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go index 4b72962d4f5..12a11d45bd1 100644 --- a/tests/integration/repo_webhook_test.go +++ b/tests/integration/repo_webhook_test.go @@ -10,12 +10,14 @@ import ( "net/http/httptest" "net/url" "path" + "strconv" "strings" "testing" "time" actions_model "code.gitea.io/gitea/models/actions" auth_model "code.gitea.io/gitea/models/auth" + db_model "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" @@ -40,26 +42,28 @@ import ( func TestNewWebHookLink(t *testing.T) { defer tests.PrepareTestEnv(t)() + require.NoError(t, db_model.Insert(t.Context(), &webhook.Webhook{ + RepoID: 1, + URL: "http://localhost/gitea-test-webhook-link", + ContentType: webhook.ContentTypeJSON, + Events: `{}`, + IsActive: true, + })) + hook := unittest.AssertExistsAndLoadBean(t, &webhook.Webhook{RepoID: 1}) session := loginUser(t, "user2") - - baseurl := "/user2/repo1/settings/hooks" - tests := []string{ - // webhook list page - baseurl, - // new webhook page - baseurl + "/gitea/new", - // edit webhook page - baseurl + "/1", + webhooksBaseHref := "/user2/repo1/settings/hooks" + cases := []string{ + webhooksBaseHref, + webhooksBaseHref + "/gitea/new", + webhooksBaseHref + "/" + strconv.FormatInt(hook.ID, 10), // edit webhook } - - for _, url := range tests { - resp := session.MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK) + for _, reqHref := range cases { + resp := session.MakeRequest(t, NewRequest(t, "GET", reqHref), http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) menus := htmlDoc.doc.Find(".ui.top.attached.header .ui.dropdown .menu a") menus.Each(func(i int, menu *goquery.Selection) { - url, exist := menu.Attr("href") - assert.True(t, exist) - assert.True(t, strings.HasPrefix(url, baseurl)) + foundHref := menu.AttrOr("href", "") + assert.True(t, strings.HasPrefix(foundHref, webhooksBaseHref)) }) } } diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go index ff35baae9db..5d5420d95d4 100644 --- a/tests/integration/signin_test.go +++ b/tests/integration/signin_test.go @@ -36,8 +36,7 @@ func testLoginFailed(t *testing.T, username, password, message string) { resp := session.MakeRequest(t, req, http.StatusOK) htmlDoc := NewHTMLParser(t, resp.Body) - resultMsg := htmlDoc.doc.Find(".ui.message>p").Text() - + resultMsg := strings.TrimSpace(htmlDoc.doc.Find(".ui.message.flash-message").Text()) assert.Equal(t, message, resultMsg) } @@ -178,6 +177,8 @@ func TestRequireSignInView(t *testing.T) { require.False(t, setting.Service.BlockAnonymousAccessExpensive) req := NewRequest(t, "GET", "/user2/repo1/src/branch/master") MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/user/events") + MakeRequest(t, req, http.StatusOK) }) t.Run("RequireSignInView", func(t *testing.T) { defer test.MockVariableValue(&setting.Service.RequireSignInViewStrict, true)() @@ -193,6 +194,8 @@ func TestRequireSignInView(t *testing.T) { req := NewRequest(t, "GET", "/user2/repo1") MakeRequest(t, req, http.StatusOK) + req = NewRequest(t, "GET", "/user/events") + MakeRequest(t, req, http.StatusSeeOther) req = NewRequest(t, "GET", "/user2/repo1/src/branch/master") resp := MakeRequest(t, req, http.StatusSeeOther) diff --git a/tests/integration/signout_test.go b/tests/integration/signout_test.go index 0c0ac5dd87c..ff35dcf7a50 100644 --- a/tests/integration/signout_test.go +++ b/tests/integration/signout_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" @@ -16,13 +17,29 @@ import ( func TestSignOut(t *testing.T) { defer tests.PrepareTestEnv(t)() - session := loginUser(t, "user2") + t.Run("NormalLogout", func(t *testing.T) { + session := loginUser(t, "user2") - req := NewRequest(t, "GET", "/user/logout") - resp := session.MakeRequest(t, req, http.StatusSeeOther) - assert.Equal(t, "/", test.RedirectURL(resp)) + req := NewRequest(t, "GET", "/user/logout") + resp := session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", resp.Header().Get("Location")) - // try to view a private repo, should fail - req = NewRequest(t, "GET", "/user2/repo2") - session.MakeRequest(t, req, http.StatusNotFound) + // logged out, try to view a private repo, should fail + req = NewRequest(t, "GET", "/user2/repo2") + session.MakeRequest(t, req, http.StatusNotFound) + }) + + t.Run("ReverseProxyLogoutRedirect", func(t *testing.T) { + defer test.MockVariableValue(&setting.Service.EnableReverseProxyAuth, true)() + defer test.MockVariableValue(&setting.ReverseProxyLogoutRedirect, "/my-sso/logout?return_to=/my-sso/home")() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/user/logout") + resp := session.MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/my-sso/logout?return_to=/my-sso/home", resp.Header().Get("Location")) + + // logged out, try to view a private repo, should fail + req = NewRequest(t, "GET", "/user2/repo2") + session.MakeRequest(t, req, http.StatusNotFound) + }) } diff --git a/tests/integration/user_settings_test.go b/tests/integration/user_settings_test.go index 20c758dc85a..b0e23b6e7ff 100644 --- a/tests/integration/user_settings_test.go +++ b/tests/integration/user_settings_test.go @@ -5,10 +5,12 @@ package integration import ( "net/http" + "strings" "testing" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" @@ -309,20 +311,26 @@ func TestUserSettingsApplications(t *testing.T) { }) resp := session.MakeRequest(t, req, http.StatusOK) doc := NewHTMLParser(t, resp.Body) - - msg := doc.Find(".flash-error p").Text() - assert.Equal(t, `form.RedirectURIs"ftp://127.0.0.1" is not a valid URL.`, msg) + msg := strings.TrimSpace(doc.Find(".ui.message.flash-message").Text()) + assert.Equal(t, `RedirectURIs: "ftp://127.0.0.1" is not a valid URL.`, msg) }) t.Run("OK", func(t *testing.T) { defer tests.PrintCurrentTest(t)() - + defer test.MockVariableValue(&setting.OAuth2.CustomSchemes, []string{"my-app"})() req := NewRequestWithValues(t, "POST", "/user/settings/applications/oauth2/2", map[string]string{ "application_name": "Test native app", "redirect_uris": "http://127.0.0.1", "confidential_client": "false", }) session.MakeRequest(t, req, http.StatusSeeOther) + + req = NewRequestWithValues(t, "POST", "/user/settings/applications/oauth2/2", map[string]string{ + "application_name": "Test native app", + "redirect_uris": "my-app://127.0.0.1", + "confidential_client": "false", + }) + session.MakeRequest(t, req, http.StatusSeeOther) }) }) }) diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go index 8981b6b3199..a124dc33c44 100644 --- a/tests/integration/user_test.go +++ b/tests/integration/user_test.go @@ -8,7 +8,9 @@ import ( "strings" "testing" + asymkey_model "code.gitea.io/gitea/models/asymkey" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" @@ -22,9 +24,20 @@ import ( "github.com/stretchr/testify/assert" ) -func TestViewUser(t *testing.T) { +func TestUser(t *testing.T) { defer tests.PrepareTestEnv(t)() + t.Run("ViewUser", testViewUser) + t.Run("RenameInvalidUsername", testRenameInvalidUsername) + t.Run("RenameReservedUsername", testRenameReservedUsername) + t.Run("ViewLimitedAndPrivateUserAndRename", testViewLimitedAndPrivateUserAndRename) + t.Run("ExportUserGPGKeys", testExportUserGPGKeys) + t.Run("GetUserRss", testGetUserRss) + t.Run("ListStopWatches", testUserListStopWatches) + t.Run("LocationMapLink", testUserLocationMapLink) + t.Run("RenameUsername", testRenameUsername) +} +func testViewUser(t *testing.T) { req := NewRequest(t, "GET", "/user2") MakeRequest(t, req, http.StatusOK) @@ -32,12 +45,28 @@ func TestViewUser(t *testing.T) { resp := MakeRequest(t, req, http.StatusOK) assert.Equal(t, `# Gitea isn't a key server. The keys are exported as the user uploaded and might not have been fully verified. ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM= +`, resp.Body.String()) + + _ = db.TruncateBeans(t.Context(), &asymkey_model.PublicKey{}) + _ = db.Insert(t.Context(), &asymkey_model.PublicKey{ + OwnerID: 2, + Name: "key-1", + Content: "ssh-rsa AAAA", + Type: asymkey_model.KeyTypeUser, + }, &asymkey_model.PublicKey{ + OwnerID: 2, + Name: "key-2", + Content: "principal", + Type: asymkey_model.KeyTypePrincipal, + }) + req = NewRequest(t, "GET", "/user2.keys") + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, `# Gitea isn't a key server. The keys are exported as the user uploaded and might not have been fully verified. +ssh-rsa AAAA `, resp.Body.String()) } -func TestRenameUsername(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testRenameUsername(t *testing.T) { session := loginUser(t, "user2") req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{ "name": "newUsername", @@ -50,9 +79,7 @@ func TestRenameUsername(t *testing.T) { unittest.AssertNotExistsBean(t, &user_model.User{Name: "user2"}) } -func TestViewLimitedAndPrivateUserAndRename(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testViewLimitedAndPrivateUserAndRename(t *testing.T) { // user 22 is a limited visibility org org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22}) req := NewRequest(t, "GET", "/"+org22.Name) @@ -119,9 +146,7 @@ func TestViewLimitedAndPrivateUserAndRename(t *testing.T) { session.MakeRequest(t, req, http.StatusTemporaryRedirect) // login user2 can visit private visibility user via old name } -func TestRenameInvalidUsername(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testRenameInvalidUsername(t *testing.T) { invalidUsernames := []string{ "%2f*", "%2f.", @@ -166,9 +191,7 @@ func TestRenameInvalidUsername(t *testing.T) { } } -func TestRenameReservedUsername(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testRenameReservedUsername(t *testing.T) { reservedUsernames := []string{ // ".", "..", ".well-known", // The names are not only reserved but also invalid "api", @@ -198,8 +221,7 @@ func TestRenameReservedUsername(t *testing.T) { } } -func TestExportUserGPGKeys(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testExportUserGPGKeys(t *testing.T) { testExportUserGPGKeys := func(t *testing.T, user, expected string) { session := loginUser(t, user) t.Logf("Testing username %s export gpg keys", user) @@ -284,9 +306,7 @@ GrE0MHOxUbc9tbtyk0F1SuzREUBH -----END PGP PUBLIC KEY BLOCK-----`) } -func TestGetUserRss(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testGetUserRss(t *testing.T) { user34 := "the_34-user.with.all.allowedChars" req := NewRequestf(t, "GET", "/%s.rss", user34) resp := MakeRequest(t, req, http.StatusOK) @@ -306,17 +326,14 @@ func TestGetUserRss(t *testing.T) { session.MakeRequest(t, req, http.StatusNotFound) } -func TestListStopWatches(t *testing.T) { - defer tests.PrepareTestEnv(t)() - +func testUserListStopWatches(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) session := loginUser(t, owner.Name) req := NewRequest(t, "GET", "/user/stopwatches") resp := session.MakeRequest(t, req, http.StatusOK) - var apiWatches []*api.StopWatch - DecodeJSON(t, resp, &apiWatches) + apiWatches := DecodeJSON(t, resp, []*api.StopWatch{}) stopwatch := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: owner.ID}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: stopwatch.IssueID}) if assert.Len(t, apiWatches, 1) { @@ -329,8 +346,7 @@ func TestListStopWatches(t *testing.T) { } } -func TestUserLocationMapLink(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testUserLocationMapLink(t *testing.T) { defer test.MockVariableValue(&setting.Service.UserLocationMapURL, "https://example/foo/")() session := loginUser(t, "user2") diff --git a/tests/integration/version_test.go b/tests/integration/version_test.go index a6ae649b400..ca716d47192 100644 --- a/tests/integration/version_test.go +++ b/tests/integration/version_test.go @@ -21,7 +21,6 @@ func TestVersion(t *testing.T) { req := NewRequest(t, "GET", "/api/v1/version") resp := MakeRequest(t, req, http.StatusOK) - var version structs.ServerVersion - DecodeJSON(t, resp, &version) + version := DecodeJSON(t, resp, &structs.ServerVersion{}) assert.Equal(t, setting.AppVer, version.Version) } diff --git a/tests/integration/view_test.go b/tests/integration/view_test.go index 9ed3e308575..4dbf7717050 100644 --- a/tests/integration/view_test.go +++ b/tests/integration/view_test.go @@ -4,17 +4,30 @@ package integration import ( + "fmt" "net/http" + "strings" "testing" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" ) -func TestRenderFileSVGIsInImgTag(t *testing.T) { +func TestView(t *testing.T) { defer tests.PrepareTestEnv(t)() + t.Run("RenderFileSVGIsInImgTag", testRenderFileSVGIsInImgTag) + t.Run("CommitListActions", testCommitListActions) + t.Run("SecurityHeadersDefaults", testSecurityHeadersDefaults) + t.Run("SiteManifest", testSiteManifest) + t.Run("CurrentURL", testViewPageCurrentURL) +} +func testRenderFileSVGIsInImgTag(t *testing.T) { session := loginUser(t, "user2") req := NewRequest(t, "GET", "/user2/repo2/src/branch/master/line.svg") @@ -26,8 +39,7 @@ func TestRenderFileSVGIsInImgTag(t *testing.T) { assert.Equal(t, "/user2/repo2/raw/branch/master/line.svg", src) } -func TestCommitListActions(t *testing.T) { - defer tests.PrepareTestEnv(t)() +func testCommitListActions(t *testing.T) { session := loginUser(t, "user2") t.Run("WikiRevisionList", func(t *testing.T) { @@ -65,3 +77,55 @@ func TestCommitListActions(t *testing.T) { AssertHTMLElement(t, htmlDoc, `.commit-list .view-commit-path`, true) }) } + +func testSecurityHeadersDefaults(t *testing.T) { + assertSecurityHeaders := func(t *testing.T, uri string) { + req := NewRequest(t, "GET", uri) + resp := MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options")) + assert.Equal(t, "SAMEORIGIN", resp.Header().Get("X-Frame-Options")) + } + assertSecurityHeaders(t, "/") + assertSecurityHeaders(t, "/api/v1/version") + assertSecurityHeaders(t, "/assets/img/favicon.png") +} + +func testSiteManifest(t *testing.T) { + req := NewRequest(t, "GET", "/") + resp := MakeRequest(t, req, http.StatusOK) + assert.Contains(t, resp.Body.String(), ``) + + req = NewRequest(t, "GET", "/assets/site-manifest.json") + resp = MakeRequest(t, req, http.StatusOK) + assert.Equal(t, "application/manifest+json", resp.Header().Get("Content-Type")) + + assetBase := strings.TrimSuffix(setting.AppURL, "/") + expectedJSON := fmt.Sprintf(`{ + "name": %q, + "short_name": %q, + "start_url": %q, + "icons": [ + {"src": %q, "type": "image/png", "sizes": "512x512"}, + {"src": %q, "type": "image/svg+xml", "sizes": "512x512"} + ] + }`, + setting.AppName, + setting.AppName, + setting.AppURL, + assetBase+"/assets/img/logo.png", + assetBase+"/assets/img/logo.svg", + ) + assert.JSONEq(t, expectedJSON, resp.Body.String()) +} + +func testViewPageCurrentURL(t *testing.T) { + defer test.MockVariableValue(&setting.AppSubURL, "/subpath")() + var currentURL string + web.RouteMock(web.MockAfterMiddlewares, func(ctx *context.Context) { + // Some custom template users need this template variable to construct links in their templates + currentURL, _ = ctx.Data["CurrentURL"].(string) + }) + defer web.RouteMockReset() + MakeRequest(t, NewRequest(t, "GET", "/any-page?k=v"), http.StatusNotFound) + assert.Equal(t, "/subpath/any-page?k=v", currentURL) +} diff --git a/tests/integration/workflow_run_api_check_test.go b/tests/integration/workflow_run_api_check_test.go index 6a80bb51186..d7390b3ac17 100644 --- a/tests/integration/workflow_run_api_check_test.go +++ b/tests/integration/workflow_run_api_check_test.go @@ -38,12 +38,15 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runI apiRunsURL := fmt.Sprintf("%s/%s", apiRootURL, "runs") req := NewRequest(t, "GET", apiRunsURL).AddTokenAuth(token) runnerListResp := MakeRequest(t, req, http.StatusOK) - runnerList := api.ActionWorkflowRunsResponse{} - DecodeJSON(t, runnerListResp, &runnerList) + runnerList := DecodeJSON(t, runnerListResp, &api.ActionWorkflowRunsResponse{}) foundRun := false for _, run := range runnerList.Entries { + if run.ID == 802 { + // Fixture stores registration event (push) and schedule as trigger; API must expose the trigger as Event. + assert.Equal(t, "schedule", run.Event) + } // Verify filtering works verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, "", run.Status, "", "", "", "") verifyWorkflowRunCanbeFoundWithStatusFilter(t, apiRunsURL, token, run.ID, run.Conclusion, "", "", "", "", "") @@ -55,8 +58,7 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runI // Verify run url works req := NewRequest(t, "GET", run.URL).AddTokenAuth(token) runResp := MakeRequest(t, req, http.StatusOK) - apiRun := api.ActionWorkflowRun{} - DecodeJSON(t, runResp, &apiRun) + apiRun := DecodeJSON(t, runResp, &api.ActionWorkflowRun{}) assert.Equal(t, run.ID, apiRun.ID) assert.Equal(t, run.Status, apiRun.Status) assert.Equal(t, run.Conclusion, apiRun.Conclusion) @@ -65,8 +67,7 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runI // Verify jobs list works req = NewRequest(t, "GET", fmt.Sprintf("%s/%s", run.URL, "jobs")).AddTokenAuth(token) jobsResp := MakeRequest(t, req, http.StatusOK) - jobList := api.ActionWorkflowJobsResponse{} - DecodeJSON(t, jobsResp, &jobList) + jobList := DecodeJSON(t, jobsResp, &api.ActionWorkflowJobsResponse{}) if run.ID == runID { foundRun = true @@ -82,8 +83,7 @@ func testAPIWorkflowRunBasic(t *testing.T, apiRootURL, userUsername string, runI // Verify job url works req := NewRequest(t, "GET", job.URL).AddTokenAuth(token) jobsResp := MakeRequest(t, req, http.StatusOK) - apiJob := api.ActionWorkflowJob{} - DecodeJSON(t, jobsResp, &apiJob) + apiJob := DecodeJSON(t, jobsResp, &api.ActionWorkflowJob{}) assert.Equal(t, job.ID, apiJob.ID) assert.Equal(t, job.RunID, apiJob.RunID) assert.Equal(t, job.Status, apiJob.Status) @@ -116,8 +116,7 @@ func verifyWorkflowRunCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token } req := NewRequest(t, "GET", runAPIURL+"?"+filter.Encode()).AddTokenAuth(token) runResp := MakeRequest(t, req, http.StatusOK) - runList := api.ActionWorkflowRunsResponse{} - DecodeJSON(t, runResp, &runList) + runList := DecodeJSON(t, runResp, &api.ActionWorkflowRunsResponse{}) found := false for _, run := range runList.Entries { @@ -151,8 +150,7 @@ func verifyWorkflowJobCanbeFoundWithStatusFilter(t *testing.T, runAPIURL, token } req := NewRequest(t, "GET", runAPIURL+"?status="+filter).AddTokenAuth(token) jobListResp := MakeRequest(t, req, http.StatusOK) - jobList := api.ActionWorkflowJobsResponse{} - DecodeJSON(t, jobListResp, &jobList) + jobList := DecodeJSON(t, jobListResp, &api.ActionWorkflowJobsResponse{}) found := false for _, job := range jobList.Entries { diff --git a/tests/test_utils.go b/tests/test_utils.go index 34645e5370b..d5a8008cefb 100644 --- a/tests/test_utils.go +++ b/tests/test_utils.go @@ -6,7 +6,9 @@ package tests import ( "database/sql" "fmt" + "os" "path/filepath" + "strings" "testing" "code.gitea.io/gitea/models/db" @@ -14,7 +16,6 @@ import ( "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/graceful" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/testlogger" @@ -24,107 +25,117 @@ import ( "github.com/stretchr/testify/assert" ) -func InitTest() { +func InitTest() error { testlogger.Init() - unittest.InitSettingsForTesting() + + if os.Getenv("GITEA_TEST_CONF") == "" { + // By default, use sqlite.ini for testing, then IDE like GoLand can start the test process with debugger. + // It's easier for developers to debug bugs step by step with a debugger. + // Notice: when doing "ssh push", Gitea executes sub processes, debugger won't work for the sub processes. + giteaConf := "tests/sqlite.ini" + _ = os.Setenv("GITEA_TEST_CONF", giteaConf) + _, _ = fmt.Fprintf(os.Stderr, "Environment variable GITEA_TEST_CONF not set - defaulting to %s\n", giteaConf) + } + setting.SetupGiteaTestEnv() setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master" if err := git.InitFull(); err != nil { - log.Fatal("git.InitOnceWithSync: %v", err) + return err } setting.LoadDBSetting() if err := storage.Init(); err != nil { - testlogger.Panicf("Init storage failed: %v\n", err) + return err } switch { case setting.Database.Type.IsMySQL(): - connType := "tcp" - if len(setting.Database.Host) > 0 && setting.Database.Host[0] == '/' { // looks like a unix socket - connType = "unix" - } - - db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@%s(%s)/", - setting.Database.User, setting.Database.Passwd, connType, setting.Database.Host)) - defer db.Close() - if err != nil { - log.Fatal("sql.Open: %v", err) - } - if _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name); err != nil { - log.Fatal("db.Exec: %v", err) - } - case setting.Database.Type.IsPostgreSQL(): - var db *sql.DB - var err error - if setting.Database.Host[0] == '/' { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host)) - } else { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) - } - - defer db.Close() - if err != nil { - log.Fatal("sql.Open: %v", err) - } - dbrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname = '%s'", setting.Database.Name)) - if err != nil { - log.Fatal("db.Query: %v", err) - } - defer dbrows.Close() - - if !dbrows.Next() { - if _, err = db.Exec("CREATE DATABASE " + setting.Database.Name); err != nil { - log.Fatal("db.Exec: CREATE DATABASE: %v", err) + { + connType := util.Iif(strings.HasPrefix(setting.Database.Host, "/"), "unix", "tcp") + db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@%s(%s)/", + setting.Database.User, setting.Database.Passwd, connType, setting.Database.Host)) + if err != nil { + return err + } + defer db.Close() + if _, err = db.Exec("CREATE DATABASE IF NOT EXISTS " + setting.Database.Name); err != nil { + return err } } - // Check if we need to setup a specific schema - if len(setting.Database.Schema) == 0 { - break - } - db.Close() - - if setting.Database.Host[0] == '/' { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s", - setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host)) - } else { - db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", + case setting.Database.Type.IsPostgreSQL(): + openPostgreSQL := func() (*sql.DB, error) { + if strings.HasPrefix(setting.Database.Host, "/") { + return sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s", + setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host)) + } + return sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s", setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode)) } - // This is a different db object; requires a different Close() - defer db.Close() - if err != nil { - log.Fatal("sql.Open: %v", err) - } - schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) - if err != nil { - log.Fatal("db.Query: %v", err) - } - defer schrows.Close() - if !schrows.Next() { - // Create and setup a DB schema - if _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema); err != nil { - log.Fatal("db.Exec: CREATE SCHEMA: %v", err) + // create database + { + db, err := openPostgreSQL() + if err != nil { + return err + } + defer db.Close() + dbRows, err := db.Query(fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname = '%s'", setting.Database.Name)) + if err != nil { + return err + } + defer dbRows.Close() + + if !dbRows.Next() { + if _, err = db.Exec("CREATE DATABASE " + setting.Database.Name); err != nil { + return err + } + } + // Check if we need to set up a specific schema + if setting.Database.Schema == "" { + break + } + db.Close() + } + + // create schema + { + db, err := openPostgreSQL() + if err != nil { + return err + } + defer db.Close() + + schemaRows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema)) + if err != nil { + return err + } + defer schemaRows.Close() + + if !schemaRows.Next() { + // Create and set up a DB schema + if _, err = db.Exec("CREATE SCHEMA " + setting.Database.Schema); err != nil { + return err + } } } case setting.Database.Type.IsMSSQL(): - host, port := setting.ParseMSSQLHostPort(setting.Database.Host) - db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", - host, port, "master", setting.Database.User, setting.Database.Passwd)) - if err != nil { - log.Fatal("sql.Open: %v", err) + { + host, port := setting.ParseMSSQLHostPort(setting.Database.Host) + db, err := sql.Open("mssql", fmt.Sprintf("server=%s; port=%s; database=%s; user id=%s; password=%s;", + host, port, "master", setting.Database.User, setting.Database.Passwd)) + if err != nil { + return err + } + defer db.Close() + if _, err = db.Exec(fmt.Sprintf("If(db_id(N'%s') IS NULL) BEGIN CREATE DATABASE %s; END;", setting.Database.Name, setting.Database.Name)); err != nil { + return err + } } - if _, err := db.Exec(fmt.Sprintf("If(db_id(N'%s') IS NULL) BEGIN CREATE DATABASE %s; END;", setting.Database.Name, setting.Database.Name)); err != nil { - log.Fatal("db.Exec: %v", err) - } - defer db.Close() } routers.InitWebInstalled(graceful.GetManager().HammerContext()) + return nil } func PrepareAttachmentsStorage(t testing.TB) { @@ -145,7 +156,7 @@ func PrepareGitRepoDirectory(t testing.TB) { if !assert.NotEmpty(t, setting.RepoRootPath) { return } - assert.NoError(t, unittest.SyncDirs(filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath)) + assert.NoError(t, unittest.SyncDirs(filepath.Join(setting.GetGiteaTestSourceRoot(), "tests/gitea-repositories-meta"), setting.RepoRootPath)) } func PrepareArtifactsStorage(t testing.TB) { diff --git a/tools/code-batch-process.go b/tools/code-batch-process.go deleted file mode 100644 index 5030d8bbc3d..00000000000 --- a/tools/code-batch-process.go +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -//go:build ignore - -package main - -import ( - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "regexp" - "slices" - "strconv" - "strings" - - "code.gitea.io/gitea/tools/codeformat" -) - -// Windows has a limitation for command line arguments, the size can not exceed 32KB. -// So we have to feed the files to some tools (like gofmt) batch by batch - -// We also introduce a `gitea-fmt` command, it does better import formatting than gofmt/goimports. `gitea-fmt` calls `gofmt` internally. - -var optionLogVerbose bool - -func logVerbose(msg string, args ...any) { - if optionLogVerbose { - log.Printf(msg, args...) - } -} - -func passThroughCmd(cmd string, args []string) error { - foundCmd, err := exec.LookPath(cmd) - if err != nil { - log.Fatalf("can not find cmd: %s", cmd) - } - c := exec.Cmd{ - Path: foundCmd, - Args: append([]string{cmd}, args...), - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - } - return c.Run() -} - -type fileCollector struct { - dirs []string - includePatterns []*regexp.Regexp - excludePatterns []*regexp.Regexp - batchSize int -} - -func newFileCollector(fileFilter string, batchSize int) (*fileCollector, error) { - co := &fileCollector{batchSize: batchSize} - if fileFilter == "go-own" { - co.dirs = []string{ - "build", - "cmd", - "contrib", - "tests", - "models", - "modules", - "routers", - "services", - } - co.includePatterns = append(co.includePatterns, regexp.MustCompile(`.*\.go$`)) - - co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`.*\bbindata\.go$`)) - co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`\.pb\.go$`)) - co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`tests/gitea-repositories-meta`)) - co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`tests/integration/migration-test`)) - co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`modules/git/tests`)) - co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`models/fixtures`)) - co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`models/migrations/fixtures`)) - co.excludePatterns = append(co.excludePatterns, regexp.MustCompile(`services/gitdiff/testdata`)) - } - - if co.dirs == nil { - return nil, fmt.Errorf("unknown file-filter: %s", fileFilter) - } - return co, nil -} - -func (fc *fileCollector) matchPatterns(path string, regexps []*regexp.Regexp) bool { - path = strings.ReplaceAll(path, "\\", "/") - for _, re := range regexps { - if re.MatchString(path) { - return true - } - } - return false -} - -func (fc *fileCollector) collectFiles() (res [][]string, err error) { - var batch []string - for _, dir := range fc.dirs { - err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - include := len(fc.includePatterns) == 0 || fc.matchPatterns(path, fc.includePatterns) - exclude := fc.matchPatterns(path, fc.excludePatterns) - process := include && !exclude - if !process { - if d.IsDir() { - if exclude { - logVerbose("exclude dir %s", path) - return filepath.SkipDir - } - // for a directory, if it is not excluded explicitly, we should walk into - return nil - } - // for a file, we skip it if it shouldn't be processed - logVerbose("skip process %s", path) - return nil - } - if d.IsDir() { - // skip dir, we don't add dirs to the file list now - return nil - } - if len(batch) >= fc.batchSize { - res = append(res, batch) - batch = nil - } - batch = append(batch, path) - return nil - }) - if err != nil { - return nil, err - } - } - res = append(res, batch) - return res, nil -} - -// substArgFiles expands the {file-list} to a real file list for commands -func substArgFiles(args, files []string) []string { - for i, s := range args { - if s == "{file-list}" { - newArgs := append(args[:i], files...) - newArgs = append(newArgs, args[i+1:]...) - return newArgs - } - } - return args -} - -func exitWithCmdErrors(subCmd string, subArgs []string, cmdErrors []error) { - for _, err := range cmdErrors { - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - exitCode := exitError.ExitCode() - log.Printf("run command failed (code=%d): %s %v", exitCode, subCmd, subArgs) - os.Exit(exitCode) - } else { - log.Fatalf("run command failed (err=%s) %s %v", err, subCmd, subArgs) - } - } - } -} - -func parseArgs() (mainOptions map[string]string, subCmd string, subArgs []string) { - mainOptions = map[string]string{} - for i := 1; i < len(os.Args); i++ { - arg := os.Args[i] - if arg == "" { - break - } - if arg[0] == '-' { - arg = strings.TrimPrefix(arg, "-") - arg = strings.TrimPrefix(arg, "-") - fields := strings.SplitN(arg, "=", 2) - if len(fields) == 1 { - mainOptions[fields[0]] = "1" - } else { - mainOptions[fields[0]] = fields[1] - } - } else { - subCmd = arg - subArgs = os.Args[i+1:] - break - } - } - return mainOptions, subCmd, subArgs -} - -func showUsage() { - fmt.Printf(`Usage: %[1]s [options] {command} [arguments] - -Options: - --verbose - --file-filter=go-own - --batch-size=100 - -Commands: - %[1]s gofmt ... - -Arguments: - {file-list} the file list - -Example: - %[1]s gofmt -s -d {file-list} - -`, "file-batch-exec") -} - -func newFileCollectorFromMainOptions(mainOptions map[string]string) (fc *fileCollector, err error) { - fileFilter := mainOptions["file-filter"] - if fileFilter == "" { - fileFilter = "go-own" - } - batchSize, _ := strconv.Atoi(mainOptions["batch-size"]) - if batchSize == 0 { - batchSize = 100 - } - - return newFileCollector(fileFilter, batchSize) -} - -func giteaFormatGoImports(files []string, doWriteFile bool) error { - for _, file := range files { - if err := codeformat.FormatGoImports(file, doWriteFile); err != nil { - log.Printf("failed to format go imports: %s, err=%v", file, err) - return err - } - } - return nil -} - -func main() { - mainOptions, subCmd, subArgs := parseArgs() - if subCmd == "" { - showUsage() - os.Exit(1) - } - optionLogVerbose = mainOptions["verbose"] != "" - - fc, err := newFileCollectorFromMainOptions(mainOptions) - if err != nil { - log.Fatalf("can not create file collector: %s", err.Error()) - } - - fileBatches, err := fc.collectFiles() - if err != nil { - log.Fatalf("can not collect files: %s", err.Error()) - } - - processed := 0 - var cmdErrors []error - for _, files := range fileBatches { - if len(files) == 0 { - break - } - substArgs := substArgFiles(subArgs, files) - logVerbose("batch cmd: %s %v", subCmd, substArgs) - switch subCmd { - case "gitea-fmt": - if slices.Contains(subArgs, "-d") { - log.Print("the -d option is not supported by gitea-fmt") - } - cmdErrors = append(cmdErrors, giteaFormatGoImports(files, slices.Contains(subArgs, "-w"))) - cmdErrors = append(cmdErrors, passThroughCmd("gofmt", append([]string{"-w", "-r", "interface{} -> any"}, substArgs...))) - cmdErrors = append(cmdErrors, passThroughCmd("go", append([]string{"run", os.Getenv("GOFUMPT_PACKAGE"), "-extra"}, substArgs...))) - default: - log.Fatalf("unknown cmd: %s %v", subCmd, subArgs) - } - processed += len(files) - } - - logVerbose("processed %d files", processed) - exitWithCmdErrors(subCmd, subArgs, cmdErrors) -} diff --git a/tools/codeformat/formatimports.go b/tools/codeformat/formatimports.go deleted file mode 100644 index c9fc2a27b4a..00000000000 --- a/tools/codeformat/formatimports.go +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package codeformat - -import ( - "bytes" - "errors" - "io" - "os" - "sort" - "strings" -) - -var importPackageGroupOrders = map[string]int{ - "": 1, // internal - "code.gitea.io/gitea/": 2, -} - -var errInvalidCommentBetweenImports = errors.New("comments between imported packages are invalid, please move comments to the end of the package line") - -var ( - importBlockBegin = []byte("\nimport (\n") - importBlockEnd = []byte("\n)") -) - -type importLineParsed struct { - group string - pkg string - content string -} - -func parseImportLine(line string) (*importLineParsed, error) { - il := &importLineParsed{content: line} - p1 := strings.IndexRune(line, '"') - if p1 == -1 { - return nil, errors.New("invalid import line: " + line) - } - p1++ - p := strings.IndexRune(line[p1:], '"') - if p == -1 { - return nil, errors.New("invalid import line: " + line) - } - p2 := p1 + p - il.pkg = line[p1:p2] - - pDot := strings.IndexRune(il.pkg, '.') - pSlash := strings.IndexRune(il.pkg, '/') - if pDot != -1 && pDot < pSlash { - il.group = "domain-package" - } - for groupName := range importPackageGroupOrders { - if groupName == "" { - continue // skip internal - } - if strings.HasPrefix(il.pkg, groupName) { - il.group = groupName - } - } - return il, nil -} - -type ( - importLineGroup []*importLineParsed - importLineGroupMap map[string]importLineGroup -) - -func formatGoImports(contentBytes []byte) ([]byte, error) { - p1 := bytes.Index(contentBytes, importBlockBegin) - if p1 == -1 { - return nil, nil - } - p1 += len(importBlockBegin) - p := bytes.Index(contentBytes[p1:], importBlockEnd) - if p == -1 { - return nil, nil - } - p2 := p1 + p - - importGroups := importLineGroupMap{} - r := bytes.NewBuffer(contentBytes[p1:p2]) - eof := false - for !eof { - line, err := r.ReadString('\n') - eof = err == io.EOF - if err != nil && !eof { - return nil, err - } - line = strings.TrimSpace(line) - if line != "" { - if strings.HasPrefix(line, "//") || strings.HasPrefix(line, "/*") { - return nil, errInvalidCommentBetweenImports - } - importLine, err := parseImportLine(line) - if err != nil { - return nil, err - } - importGroups[importLine.group] = append(importGroups[importLine.group], importLine) - } - } - - var groupNames []string - for groupName, importLines := range importGroups { - groupNames = append(groupNames, groupName) - sort.Slice(importLines, func(i, j int) bool { - return strings.Compare(importLines[i].pkg, importLines[j].pkg) < 0 - }) - } - - sort.Slice(groupNames, func(i, j int) bool { - n1 := groupNames[i] - n2 := groupNames[j] - o1 := importPackageGroupOrders[n1] - o2 := importPackageGroupOrders[n2] - if o1 != 0 && o2 != 0 { - return o1 < o2 - } - if o1 == 0 && o2 == 0 { - return strings.Compare(n1, n2) < 0 - } - return o1 != 0 - }) - - formattedBlock := bytes.Buffer{} - for _, groupName := range groupNames { - hasNormalImports := false - hasDummyImports := false - // non-dummy import comes first - for _, importLine := range importGroups[groupName] { - if strings.HasPrefix(importLine.content, "_") { - hasDummyImports = true - } else { - formattedBlock.WriteString("\t" + importLine.content + "\n") - hasNormalImports = true - } - } - // dummy (_ "pkg") comes later - if hasDummyImports { - if hasNormalImports { - formattedBlock.WriteString("\n") - } - for _, importLine := range importGroups[groupName] { - if strings.HasPrefix(importLine.content, "_") { - formattedBlock.WriteString("\t" + importLine.content + "\n") - } - } - } - formattedBlock.WriteString("\n") - } - formattedBlockBytes := bytes.TrimRight(formattedBlock.Bytes(), "\n") - - var formattedBytes []byte - formattedBytes = append(formattedBytes, contentBytes[:p1]...) - formattedBytes = append(formattedBytes, formattedBlockBytes...) - formattedBytes = append(formattedBytes, contentBytes[p2:]...) - return formattedBytes, nil -} - -// FormatGoImports format the imports by our rules (see unit tests) -func FormatGoImports(file string, doWriteFile bool) error { - f, err := os.Open(file) - if err != nil { - return err - } - var contentBytes []byte - { - defer f.Close() - contentBytes, err = io.ReadAll(f) - if err != nil { - return err - } - } - formattedBytes, err := formatGoImports(contentBytes) - if err != nil { - return err - } - if formattedBytes == nil { - return nil - } - if bytes.Equal(contentBytes, formattedBytes) { - return nil - } - - if doWriteFile { - f, err = os.OpenFile(file, os.O_TRUNC|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - _, err = f.Write(formattedBytes) - return err - } - - return err -} diff --git a/tools/codeformat/formatimports_test.go b/tools/codeformat/formatimports_test.go deleted file mode 100644 index c66181d3513..00000000000 --- a/tools/codeformat/formatimports_test.go +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package codeformat - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestFormatImportsSimple(t *testing.T) { - formatted, err := formatGoImports([]byte(` -package codeformat - -import ( - "github.com/stretchr/testify/assert" - "testing" -) -`)) - - expected := ` -package codeformat - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) -` - - assert.NoError(t, err) - assert.Equal(t, expected, string(formatted)) -} - -func TestFormatImportsGroup(t *testing.T) { - // gofmt/goimports won't group the packages, for example, they produce such code: - // "bytes" - // "image" - // (a blank line) - // "fmt" - // "image/color/palette" - // our formatter does better, and these packages are grouped into one. - - formatted, err := formatGoImports([]byte(` -package test - -import ( - "bytes" - "fmt" - "image" - "image/color" - - _ "image/gif" // for processing gif images - _ "image/jpeg" // for processing jpeg images - _ "image/png" // for processing png images - - "code.gitea.io/other/package" - - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" - - "xorm.io/the/package" - - "github.com/issue9/identicon" - "github.com/nfnt/resize" - "github.com/oliamb/cutter" -) -`)) - - expected := ` -package test - -import ( - "bytes" - "fmt" - "image" - "image/color" - - _ "image/gif" // for processing gif images - _ "image/jpeg" // for processing jpeg images - _ "image/png" // for processing png images - - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" - - "code.gitea.io/other/package" - "github.com/issue9/identicon" - "github.com/nfnt/resize" - "github.com/oliamb/cutter" - "xorm.io/the/package" -) -` - - assert.NoError(t, err) - assert.Equal(t, expected, string(formatted)) -} - -func TestFormatImportsInvalidComment(t *testing.T) { - // why we shouldn't write comments between imports: it breaks the grouping of imports - // for example: - // "pkg1" - // "pkg2" - // // a comment - // "pkgA" - // "pkgB" - // the comment splits the packages into two groups, pkg1/2 are sorted separately, pkgA/B are sorted separately - // we don't want such code, so the code should be: - // "pkg1" - // "pkg2" - // "pkgA" // a comment - // "pkgB" - - _, err := formatGoImports([]byte(` -package test - -import ( - "image/jpeg" - // for processing gif images - "image/gif" -) -`)) - assert.ErrorIs(t, err, errInvalidCommentBetweenImports) -} diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index b8bf6718392..39405387b5b 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -95,10 +95,11 @@ GITEA_TEST_E2E_EMAIL="$GITEA_TEST_E2E_USER@$GITEA_TEST_E2E_DOMAIN" --must-change-password=false \ --admin -# timeout multiplier, CI runners are slower +# timeout multiplier to make the tests pass on slow CI runners while using +# factor 1 on a fast local machine like a MacBook Pro M1+ if [ -z "${GITEA_TEST_E2E_TIMEOUT_FACTOR:-}" ]; then if [ -n "${CI:-}" ]; then - GITEA_TEST_E2E_TIMEOUT_FACTOR=3 + GITEA_TEST_E2E_TIMEOUT_FACTOR=4 else GITEA_TEST_E2E_TIMEOUT_FACTOR=1 fi diff --git a/tsconfig.json b/tsconfig.json index 92cf43fbf82..bcecb172172 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,8 +35,6 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "strictPropertyInitialization": false, - "useUnknownInCatchVariables": false, "stripInternal": true, "verbatimModuleSyntax": true, "types": [ diff --git a/types.d.ts b/types.d.ts index 234bd267fe2..bdf35428bc6 100644 --- a/types.d.ts +++ b/types.d.ts @@ -36,9 +36,11 @@ declare module '*.vue' { export function initRepositoryActionView(): void; } -declare module 'htmx.org/dist/htmx.esm.js' { - const value = await import('htmx.org'); - export default value; +declare module 'idiomorph' { + interface Idiomorph { + morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void; + } + export const Idiomorph: Idiomorph; } declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' { diff --git a/updates.config.ts b/updates.config.ts deleted file mode 100644 index 787e6dc7c6f..00000000000 --- a/updates.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type {Config} from 'updates'; - -export default { - pin: { - '@mcaptcha/vanilla-glue': '^0.1', // breaking changes in rc versions need to be handled - 'cropperjs': '^1', // need to migrate to v2 but v2 is not compatible with v1 - 'tailwindcss': '^3', // need to migrate - }, -} satisfies Config; diff --git a/uv.lock b/uv.lock index a29ea97b97d..608ec69f7f1 100644 --- a/uv.lock +++ b/uv.lock @@ -4,14 +4,14 @@ requires-python = ">=3.10" [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -118,11 +118,11 @@ wheels = [ [[package]] name = "json5" -version = "0.13.0" +version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb", size = 52656, upload-time = "2026-03-27T22:50:48.108Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, ] [[package]] @@ -200,123 +200,123 @@ wheels = [ [[package]] name = "regex" -version = "2026.2.19" +version = "2026.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/c0/d8079d4f6342e4cec5c3e7d7415b5cd3e633d5f4124f7a4626908dbe84c7/regex-2026.2.19.tar.gz", hash = "sha256:6fb8cb09b10e38f3ae17cc6dc04a1df77762bd0351b6ba9041438e7cc85ec310", size = 414973, upload-time = "2026-02-19T19:03:47.899Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/de/f10b4506acfd684de4e42b0aa56ccea1a778a18864da8f6d319a40591062/regex-2026.2.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f5a37a17d110f9d5357a43aa7e3507cb077bf3143d1c549a45c4649e90e40a70", size = 488369, upload-time = "2026-02-19T18:59:45.01Z" }, - { url = "https://files.pythonhosted.org/packages/8b/2f/b4eaef1f0b4d0bf2a73eaf07c08f6c13422918a4180c9211ce0521746d0c/regex-2026.2.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:676c4e6847a83a1d5732b4ed553881ad36f0a8133627bb695a89ecf3571499d3", size = 290743, upload-time = "2026-02-19T18:59:48.527Z" }, - { url = "https://files.pythonhosted.org/packages/76/7c/805413bd0a88d04688c0725c222cfb811bd54a2f571004c24199a1ae55d6/regex-2026.2.19-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82336faeecac33297cd42857c3b36f12b91810e3fdd276befdd128f73a2b43fa", size = 288652, upload-time = "2026-02-19T18:59:50.2Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/2c4cd530a878b1975398e76faef4285f11e7c9ccf1aaedfd528bfcc1f580/regex-2026.2.19-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52136f5b71f095cb74b736cc3a1b578030dada2e361ef2f07ca582240b703946", size = 781759, upload-time = "2026-02-19T18:59:51.836Z" }, - { url = "https://files.pythonhosted.org/packages/37/45/9608ab1b41f6740ff4076eabadde8e8b3f3400942b348ac41e8599ccc131/regex-2026.2.19-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4192464fe3e6cb0ef6751f7d3b16f886d8270d359ed1590dd555539d364f0ff7", size = 850947, upload-time = "2026-02-19T18:59:53.739Z" }, - { url = "https://files.pythonhosted.org/packages/90/3a/66471b6c4f7cac17e14bf5300e46661bba2b17ffb0871bd2759e837a6f82/regex-2026.2.19-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e561dd47a85d2660d3d3af4e6cb2da825cf20f121e577147963f875b83d32786", size = 898794, upload-time = "2026-02-19T18:59:55.993Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d2/38c53929a5931f7398e5e49f5a5a3079cb2aba30119b4350608364cfad8c/regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00ec994d7824bf01cd6c7d14c7a6a04d9aeaf7c42a2bc22d2359d715634d539b", size = 791922, upload-time = "2026-02-19T18:59:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bd/b046e065630fa25059d9c195b7b5308ea94da45eee65d40879772500f74c/regex-2026.2.19-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2cb00aabd96b345d56a8c2bc328c8d6c4d29935061e05078bf1f02302e12abf5", size = 783345, upload-time = "2026-02-19T18:59:59.948Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8f/045c643d2fa255a985e8f87d848e4be230b711a8935e4bdc58e60b8f7b84/regex-2026.2.19-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f374366ed35673ea81b86a8859c457d4fae6ba092b71024857e9e237410c7404", size = 768055, upload-time = "2026-02-19T19:00:01.65Z" }, - { url = "https://files.pythonhosted.org/packages/72/9f/ab7ae9f5447559562f1a788bbc85c0e526528c5e6c20542d18e4afc86aad/regex-2026.2.19-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9417fd853fcd00b7d55167e692966dd12d95ba1a88bf08a62002ccd85030790", size = 774955, upload-time = "2026-02-19T19:00:03.368Z" }, - { url = "https://files.pythonhosted.org/packages/37/5c/f16fc23c56f60b6f4ff194604a6e53bb8aec7b6e8e4a23a482dee8d77235/regex-2026.2.19-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:12e86a01594031abf892686fcb309b041bf3de3d13d99eb7e2b02a8f3c687df1", size = 846010, upload-time = "2026-02-19T19:00:05.079Z" }, - { url = "https://files.pythonhosted.org/packages/51/c8/6be4c854135d7c9f35d4deeafdaf124b039ecb4ffcaeb7ed0495ad2c97ca/regex-2026.2.19-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:79014115e6fdf18fd9b32e291d58181bf42d4298642beaa13fd73e69810e4cb6", size = 755938, upload-time = "2026-02-19T19:00:07.148Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8d/f683d49b9663a5324b95a328e69d397f6dade7cb84154eec116bf79fe150/regex-2026.2.19-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:31aefac2506967b7dd69af2c58eca3cc8b086d4110b66d6ac6e9026f0ee5b697", size = 835773, upload-time = "2026-02-19T19:00:08.939Z" }, - { url = "https://files.pythonhosted.org/packages/16/cd/619224b90da09f167fe4497c350a0d0b30edc539ee9244bf93e604c073c3/regex-2026.2.19-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:49cef7bb2a491f91a8869c7cdd90babf0a417047ab0bf923cd038ed2eab2ccb8", size = 780075, upload-time = "2026-02-19T19:00:10.838Z" }, - { url = "https://files.pythonhosted.org/packages/5b/88/19cfb0c262d6f9d722edef29157125418bf90eb3508186bf79335afeedae/regex-2026.2.19-cp310-cp310-win32.whl", hash = "sha256:3a039474986e7a314ace6efb9ce52f5da2bdb80ac4955358723d350ec85c32ad", size = 266004, upload-time = "2026-02-19T19:00:12.371Z" }, - { url = "https://files.pythonhosted.org/packages/82/af/5b487e0287ef72545d7ae92edecdacbe3d44e531cac24fda7de5598ba8dd/regex-2026.2.19-cp310-cp310-win_amd64.whl", hash = "sha256:5b81ff4f9cad99f90c807a00c5882fbcda86d8b3edd94e709fb531fc52cb3d25", size = 277895, upload-time = "2026-02-19T19:00:13.75Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/b6715a187ffca4d2979af92a46ce922445ba41f910bf187ccd666a2d52ef/regex-2026.2.19-cp310-cp310-win_arm64.whl", hash = "sha256:a032bc01a4bc73fc3cadba793fce28eb420da39338f47910c59ffcc11a5ba5ef", size = 270465, upload-time = "2026-02-19T19:00:15.127Z" }, - { url = "https://files.pythonhosted.org/packages/6f/93/43f405a98f54cc59c786efb4fc0b644615ed2392fc89d57d30da11f35b5b/regex-2026.2.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:93b16a18cadb938f0f2306267161d57eb33081a861cee9ffcd71e60941eb5dfc", size = 488365, upload-time = "2026-02-19T19:00:17.857Z" }, - { url = "https://files.pythonhosted.org/packages/66/46/da0efce22cd8f5ae28eeb25ac69703f49edcad3331ac22440776f4ea0867/regex-2026.2.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78af1e499cab704131f6f4e2f155b7f54ce396ca2acb6ef21a49507e4752e0be", size = 290737, upload-time = "2026-02-19T19:00:19.869Z" }, - { url = "https://files.pythonhosted.org/packages/fb/19/f735078448132c1c974974d30d5306337bc297fe6b6f126164bff72c1019/regex-2026.2.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eb20c11aa4c3793c9ad04c19a972078cdadb261b8429380364be28e867a843f2", size = 288654, upload-time = "2026-02-19T19:00:21.307Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/6d7c24a2f423c03ad03e3fbddefa431057186ac1c4cb4fa98b03c7f39808/regex-2026.2.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db5fd91eec71e7b08de10011a2223d0faa20448d4e1380b9daa179fa7bf58906", size = 793785, upload-time = "2026-02-19T19:00:22.926Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/fdb8107504b3122a79bde6705ac1f9d495ed1fe35b87d7cfc1864471999a/regex-2026.2.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fdbade8acba71bb45057c2b72f477f0b527c4895f9c83e6cfc30d4a006c21726", size = 860731, upload-time = "2026-02-19T19:00:25.196Z" }, - { url = "https://files.pythonhosted.org/packages/9a/fd/cc8c6f05868defd840be6e75919b1c3f462357969ac2c2a0958363b4dc23/regex-2026.2.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:31a5f561eb111d6aae14202e7043fb0b406d3c8dddbbb9e60851725c9b38ab1d", size = 907350, upload-time = "2026-02-19T19:00:27.093Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1b/4590db9caa8db3d5a3fe31197c4e42c15aab3643b549ef6a454525fa3a61/regex-2026.2.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4584a3ee5f257b71e4b693cc9be3a5104249399f4116fe518c3f79b0c6fc7083", size = 800628, upload-time = "2026-02-19T19:00:29.392Z" }, - { url = "https://files.pythonhosted.org/packages/76/05/513eaa5b96fa579fd0b813e19ec047baaaf573d7374ff010fa139b384bf7/regex-2026.2.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:196553ba2a2f47904e5dc272d948a746352e2644005627467e055be19d73b39e", size = 773711, upload-time = "2026-02-19T19:00:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/95/65/5aed06d8c54563d37fea496cf888be504879a3981a7c8e12c24b2c92c209/regex-2026.2.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0c10869d18abb759a3317c757746cc913d6324ce128b8bcec99350df10419f18", size = 783186, upload-time = "2026-02-19T19:00:34.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/57/79a633ad90f2371b4ef9cd72ba3a69a1a67d0cfaab4fe6fa8586d46044ef/regex-2026.2.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e689fed279cbe797a6b570bd18ff535b284d057202692c73420cb93cca41aa32", size = 854854, upload-time = "2026-02-19T19:00:37.306Z" }, - { url = "https://files.pythonhosted.org/packages/eb/2d/0f113d477d9e91ec4545ec36c82e58be25038d06788229c91ad52da2b7f5/regex-2026.2.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0782bd983f19ac7594039c9277cd6f75c89598c1d72f417e4d30d874105eb0c7", size = 762279, upload-time = "2026-02-19T19:00:39.793Z" }, - { url = "https://files.pythonhosted.org/packages/39/cb/237e9fa4f61469fd4f037164dbe8e675a376c88cf73aaaa0aedfd305601c/regex-2026.2.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:dbb240c81cfed5d4a67cb86d7676d9f7ec9c3f186310bec37d8a1415210e111e", size = 846172, upload-time = "2026-02-19T19:00:42.134Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7c/104779c5915cc4eb557a33590f8a3f68089269c64287dd769afd76c7ce61/regex-2026.2.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80d31c3f1fe7e4c6cd1831cd4478a0609903044dfcdc4660abfe6fb307add7f0", size = 789078, upload-time = "2026-02-19T19:00:43.908Z" }, - { url = "https://files.pythonhosted.org/packages/a8/4a/eae4e88b1317fb2ff57794915e0099198f51e760f6280b320adfa0ad396d/regex-2026.2.19-cp311-cp311-win32.whl", hash = "sha256:66e6a43225ff1064f8926adbafe0922b370d381c3330edaf9891cade52daa790", size = 266013, upload-time = "2026-02-19T19:00:47.274Z" }, - { url = "https://files.pythonhosted.org/packages/f9/29/ba89eb8fae79705e07ad1bd69e568f776159d2a8093c9dbc5303ee618298/regex-2026.2.19-cp311-cp311-win_amd64.whl", hash = "sha256:59a7a5216485a1896c5800e9feb8ff9213e11967b482633b6195d7da11450013", size = 277906, upload-time = "2026-02-19T19:00:49.011Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1a/042d8f04b28e318df92df69d8becb0f42221eb3dd4fe5e976522f4337c76/regex-2026.2.19-cp311-cp311-win_arm64.whl", hash = "sha256:ec661807ffc14c8d14bb0b8c1bb3d5906e476bc96f98b565b709d03962ee4dd4", size = 270463, upload-time = "2026-02-19T19:00:50.988Z" }, - { url = "https://files.pythonhosted.org/packages/b3/73/13b39c7c9356f333e564ab4790b6cb0df125b8e64e8d6474e73da49b1955/regex-2026.2.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c1665138776e4ac1aa75146669236f7a8a696433ec4e525abf092ca9189247cc", size = 489541, upload-time = "2026-02-19T19:00:52.728Z" }, - { url = "https://files.pythonhosted.org/packages/15/77/fcc7bd9a67000d07fbcc11ed226077287a40d5c84544e62171d29d3ef59c/regex-2026.2.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d792b84709021945597e05656aac059526df4e0c9ef60a0eaebb306f8fafcaa8", size = 291414, upload-time = "2026-02-19T19:00:54.51Z" }, - { url = "https://files.pythonhosted.org/packages/f9/87/3997fc72dc59233426ef2e18dfdd105bb123812fff740ee9cc348f1a3243/regex-2026.2.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:db970bcce4d63b37b3f9eb8c893f0db980bbf1d404a1d8d2b17aa8189de92c53", size = 289140, upload-time = "2026-02-19T19:00:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d0/b7dd3883ed1cff8ee0c0c9462d828aaf12be63bf5dc55453cbf423523b13/regex-2026.2.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03d706fbe7dfec503c8c3cb76f9352b3e3b53b623672aa49f18a251a6c71b8e6", size = 798767, upload-time = "2026-02-19T19:00:59.014Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7e/8e2d09103832891b2b735a2515abf377db21144c6dd5ede1fb03c619bf09/regex-2026.2.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dbff048c042beef60aa1848961384572c5afb9e8b290b0f1203a5c42cf5af65", size = 864436, upload-time = "2026-02-19T19:01:00.772Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2e/afea8d23a6db1f67f45e3a0da3057104ce32e154f57dd0c8997274d45fcd/regex-2026.2.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccaaf9b907ea6b4223d5cbf5fa5dff5f33dc66f4907a25b967b8a81339a6e332", size = 912391, upload-time = "2026-02-19T19:01:02.865Z" }, - { url = "https://files.pythonhosted.org/packages/59/3c/ea5a4687adaba5e125b9bd6190153d0037325a0ba3757cc1537cc2c8dd90/regex-2026.2.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75472631eee7898e16a8a20998d15106cb31cfde21cdf96ab40b432a7082af06", size = 803702, upload-time = "2026-02-19T19:01:05.298Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c5/624a0705e8473a26488ec1a3a4e0b8763ecfc682a185c302dfec71daea35/regex-2026.2.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d89f85a5ccc0cec125c24be75610d433d65295827ebaf0d884cbe56df82d4774", size = 775980, upload-time = "2026-02-19T19:01:07.047Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/ed776642533232b5599b7c1f9d817fe11faf597e8a92b7a44b841daaae76/regex-2026.2.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9f81806abdca3234c3dd582b8a97492e93de3602c8772013cb4affa12d1668", size = 788122, upload-time = "2026-02-19T19:01:08.744Z" }, - { url = "https://files.pythonhosted.org/packages/8c/58/e93e093921d13b9784b4f69896b6e2a9e09580a265c59d9eb95e87d288f2/regex-2026.2.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9dadc10d1c2bbb1326e572a226d2ec56474ab8aab26fdb8cf19419b372c349a9", size = 858910, upload-time = "2026-02-19T19:01:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/85/77/ff1d25a0c56cd546e0455cbc93235beb33474899690e6a361fa6b52d265b/regex-2026.2.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6bc25d7e15f80c9dc7853cbb490b91c1ec7310808b09d56bd278fe03d776f4f6", size = 764153, upload-time = "2026-02-19T19:01:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ef/8ec58df26d52d04443b1dc56f9be4b409f43ed5ae6c0248a287f52311fc4/regex-2026.2.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:965d59792f5037d9138da6fed50ba943162160443b43d4895b182551805aff9c", size = 850348, upload-time = "2026-02-19T19:01:14.147Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b3/c42fd5ed91639ce5a4225b9df909180fc95586db071f2bf7c68d2ccbfbe6/regex-2026.2.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:38d88c6ed4a09ed61403dbdf515d969ccba34669af3961ceb7311ecd0cef504a", size = 789977, upload-time = "2026-02-19T19:01:15.838Z" }, - { url = "https://files.pythonhosted.org/packages/b6/22/bc3b58ebddbfd6ca5633e71fd41829ee931963aad1ebeec55aad0c23044e/regex-2026.2.19-cp312-cp312-win32.whl", hash = "sha256:5df947cabab4b643d4791af5e28aecf6bf62e6160e525651a12eba3d03755e6b", size = 266381, upload-time = "2026-02-19T19:01:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4a/6ff550b63e67603ee60e69dc6bd2d5694e85046a558f663b2434bdaeb285/regex-2026.2.19-cp312-cp312-win_amd64.whl", hash = "sha256:4146dc576ea99634ae9c15587d0c43273b4023a10702998edf0fa68ccb60237a", size = 277274, upload-time = "2026-02-19T19:01:19.826Z" }, - { url = "https://files.pythonhosted.org/packages/cc/29/9ec48b679b1e87e7bc8517dff45351eab38f74fbbda1fbcf0e9e6d4e8174/regex-2026.2.19-cp312-cp312-win_arm64.whl", hash = "sha256:cdc0a80f679353bd68450d2a42996090c30b2e15ca90ded6156c31f1a3b63f3b", size = 270509, upload-time = "2026-02-19T19:01:22.075Z" }, - { url = "https://files.pythonhosted.org/packages/d2/2d/a849835e76ac88fcf9e8784e642d3ea635d183c4112150ca91499d6703af/regex-2026.2.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8df08decd339e8b3f6a2eb5c05c687fe9d963ae91f352bc57beb05f5b2ac6879", size = 489329, upload-time = "2026-02-19T19:01:23.841Z" }, - { url = "https://files.pythonhosted.org/packages/da/aa/78ff4666d3855490bae87845a5983485e765e1f970da20adffa2937b241d/regex-2026.2.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3aa0944f1dc6e92f91f3b306ba7f851e1009398c84bfd370633182ee4fc26a64", size = 291308, upload-time = "2026-02-19T19:01:25.605Z" }, - { url = "https://files.pythonhosted.org/packages/cd/58/714384efcc07ae6beba528a541f6e99188c5cc1bc0295337f4e8a868296d/regex-2026.2.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c13228fbecb03eadbfd8f521732c5fda09ef761af02e920a3148e18ad0e09968", size = 289033, upload-time = "2026-02-19T19:01:27.243Z" }, - { url = "https://files.pythonhosted.org/packages/75/ec/6438a9344d2869cf5265236a06af1ca6d885e5848b6561e10629bc8e5a11/regex-2026.2.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d0e72703c60d68b18b27cde7cdb65ed2570ae29fb37231aa3076bfb6b1d1c13", size = 798798, upload-time = "2026-02-19T19:01:28.877Z" }, - { url = "https://files.pythonhosted.org/packages/c2/be/b1ce2d395e3fd2ce5f2fde2522f76cade4297cfe84cd61990ff48308749c/regex-2026.2.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46e69a4bf552e30e74a8aa73f473c87efcb7f6e8c8ece60d9fd7bf13d5c86f02", size = 864444, upload-time = "2026-02-19T19:01:30.933Z" }, - { url = "https://files.pythonhosted.org/packages/d5/97/a3406460c504f7136f140d9461960c25f058b0240e4424d6fb73c7a067ab/regex-2026.2.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8edda06079bd770f7f0cf7f3bba1a0b447b96b4a543c91fe0c142d034c166161", size = 912633, upload-time = "2026-02-19T19:01:32.744Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d9/e5dbef95008d84e9af1dc0faabbc34a7fbc8daa05bc5807c5cf86c2bec49/regex-2026.2.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cbc69eae834afbf634f7c902fc72ff3e993f1c699156dd1af1adab5d06b7fe7", size = 803718, upload-time = "2026-02-19T19:01:34.61Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e5/61d80132690a1ef8dc48e0f44248036877aebf94235d43f63a20d1598888/regex-2026.2.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bcf57d30659996ee5c7937999874504c11b5a068edc9515e6a59221cc2744dd1", size = 775975, upload-time = "2026-02-19T19:01:36.525Z" }, - { url = "https://files.pythonhosted.org/packages/05/32/ae828b3b312c972cf228b634447de27237d593d61505e6ad84723f8eabba/regex-2026.2.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8e6e77cd92216eb489e21e5652a11b186afe9bdefca8a2db739fd6b205a9e0a4", size = 788129, upload-time = "2026-02-19T19:01:38.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/25/d74f34676f22bec401eddf0e5e457296941e10cbb2a49a571ca7a2c16e5a/regex-2026.2.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9ab8dec42afefa6314ea9b31b188259ffdd93f433d77cad454cd0b8d235ce1c", size = 858818, upload-time = "2026-02-19T19:01:40.409Z" }, - { url = "https://files.pythonhosted.org/packages/1e/eb/0bc2b01a6b0b264e1406e5ef11cae3f634c3bd1a6e61206fd3227ce8e89c/regex-2026.2.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:294c0fb2e87c6bcc5f577c8f609210f5700b993151913352ed6c6af42f30f95f", size = 764186, upload-time = "2026-02-19T19:01:43.009Z" }, - { url = "https://files.pythonhosted.org/packages/eb/37/5fe5a630d0d99ecf0c3570f8905dafbc160443a2d80181607770086c9812/regex-2026.2.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c0924c64b082d4512b923ac016d6e1dcf647a3560b8a4c7e55cbbd13656cb4ed", size = 850363, upload-time = "2026-02-19T19:01:45.015Z" }, - { url = "https://files.pythonhosted.org/packages/c3/45/ef68d805294b01ec030cfd388724ba76a5a21a67f32af05b17924520cb0b/regex-2026.2.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:790dbf87b0361606cb0d79b393c3e8f4436a14ee56568a7463014565d97da02a", size = 790026, upload-time = "2026-02-19T19:01:47.51Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3a/40d3b66923dfc5aeba182f194f0ca35d09afe8c031a193e6ae46971a0a0e/regex-2026.2.19-cp313-cp313-win32.whl", hash = "sha256:43cdde87006271be6963896ed816733b10967baaf0e271d529c82e93da66675b", size = 266372, upload-time = "2026-02-19T19:01:49.469Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f2/39082e8739bfd553497689e74f9d5e5bb531d6f8936d0b94f43e18f219c0/regex-2026.2.19-cp313-cp313-win_amd64.whl", hash = "sha256:127ea69273485348a126ebbf3d6052604d3c7da284f797bba781f364c0947d47", size = 277253, upload-time = "2026-02-19T19:01:51.208Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c2/852b9600d53fb47e47080c203e2cdc0ac7e84e37032a57e0eaa37446033a/regex-2026.2.19-cp313-cp313-win_arm64.whl", hash = "sha256:5e56c669535ac59cbf96ca1ece0ef26cb66809990cda4fa45e1e32c3b146599e", size = 270505, upload-time = "2026-02-19T19:01:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/a9/a2/e0b4575b93bc84db3b1fab24183e008691cd2db5c0ef14ed52681fbd94dd/regex-2026.2.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:93d881cab5afdc41a005dba1524a40947d6f7a525057aa64aaf16065cf62faa9", size = 492202, upload-time = "2026-02-19T19:01:54.816Z" }, - { url = "https://files.pythonhosted.org/packages/24/b5/b84fec8cbb5f92a7eed2b6b5353a6a9eed9670fee31817c2da9eb85dc797/regex-2026.2.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:80caaa1ddcc942ec7be18427354f9d58a79cee82dea2a6b3d4fd83302e1240d7", size = 292884, upload-time = "2026-02-19T19:01:58.254Z" }, - { url = "https://files.pythonhosted.org/packages/70/0c/fe89966dfae43da46f475362401f03e4d7dc3a3c955b54f632abc52669e0/regex-2026.2.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d793c5b4d2b4c668524cd1651404cfc798d40694c759aec997e196fe9729ec60", size = 291236, upload-time = "2026-02-19T19:01:59.966Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f7/bda2695134f3e63eb5cccbbf608c2a12aab93d261ff4e2fe49b47fabc948/regex-2026.2.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5100acb20648d9efd3f4e7e91f51187f95f22a741dcd719548a6cf4e1b34b3f", size = 807660, upload-time = "2026-02-19T19:02:01.632Z" }, - { url = "https://files.pythonhosted.org/packages/11/56/6e3a4bf5e60d17326b7003d91bbde8938e439256dec211d835597a44972d/regex-2026.2.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e3a31e94d10e52a896adaa3adf3621bd526ad2b45b8c2d23d1bbe74c7423007", size = 873585, upload-time = "2026-02-19T19:02:03.522Z" }, - { url = "https://files.pythonhosted.org/packages/35/5e/c90c6aa4d1317cc11839359479cfdd2662608f339e84e81ba751c8a4e461/regex-2026.2.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8497421099b981f67c99eba4154cf0dfd8e47159431427a11cfb6487f7791d9e", size = 915243, upload-time = "2026-02-19T19:02:05.608Z" }, - { url = "https://files.pythonhosted.org/packages/90/7c/981ea0694116793001496aaf9524e5c99e122ec3952d9e7f1878af3a6bf1/regex-2026.2.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e7a08622f7d51d7a068f7e4052a38739c412a3e74f55817073d2e2418149619", size = 812922, upload-time = "2026-02-19T19:02:08.115Z" }, - { url = "https://files.pythonhosted.org/packages/2d/be/9eda82afa425370ffdb3fa9f3ea42450b9ae4da3ff0a4ec20466f69e371b/regex-2026.2.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8abe671cf0f15c26b1ad389bf4043b068ce7d3b1c5d9313e12895f57d6738555", size = 781318, upload-time = "2026-02-19T19:02:10.072Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d5/50f0bbe56a8199f60a7b6c714e06e54b76b33d31806a69d0703b23ce2a9e/regex-2026.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5a8f28dd32a4ce9c41758d43b5b9115c1c497b4b1f50c457602c1d571fa98ce1", size = 795649, upload-time = "2026-02-19T19:02:11.96Z" }, - { url = "https://files.pythonhosted.org/packages/c5/09/d039f081e44a8b0134d0bb2dd805b0ddf390b69d0b58297ae098847c572f/regex-2026.2.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:654dc41a5ba9b8cc8432b3f1aa8906d8b45f3e9502442a07c2f27f6c63f85db5", size = 868844, upload-time = "2026-02-19T19:02:14.043Z" }, - { url = "https://files.pythonhosted.org/packages/ef/53/e2903b79a19ec8557fe7cd21cd093956ff2dbc2e0e33969e3adbe5b184dd/regex-2026.2.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4a02faea614e7fdd6ba8b3bec6c8e79529d356b100381cec76e638f45d12ca04", size = 770113, upload-time = "2026-02-19T19:02:16.161Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e2/784667767b55714ebb4e59bf106362327476b882c0b2f93c25e84cc99b1a/regex-2026.2.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d96162140bb819814428800934c7b71b7bffe81fb6da2d6abc1dcca31741eca3", size = 854922, upload-time = "2026-02-19T19:02:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/59/78/9ef4356bd4aed752775bd18071034979b85f035fec51f3a4f9dea497a254/regex-2026.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c227f2922153ee42bbeb355fd6d009f8c81d9d7bdd666e2276ce41f53ed9a743", size = 799636, upload-time = "2026-02-19T19:02:20.04Z" }, - { url = "https://files.pythonhosted.org/packages/cf/54/fcfc9287f20c5c9bd8db755aafe3e8cf4d99a6a3f1c7162ee182e0ca9374/regex-2026.2.19-cp313-cp313t-win32.whl", hash = "sha256:a178df8ec03011153fbcd2c70cb961bc98cbbd9694b28f706c318bee8927c3db", size = 268968, upload-time = "2026-02-19T19:02:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/1e/a0/ff24c6cb1273e42472706d277147fc38e1f9074a280fb6034b0fc9b69415/regex-2026.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:2c1693ca6f444d554aa246b592355b5cec030ace5a2729eae1b04ab6e853e768", size = 280390, upload-time = "2026-02-19T19:02:25.231Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b6/a3f6ad89d780ffdeebb4d5e2e3e30bd2ef1f70f6a94d1760e03dd1e12c60/regex-2026.2.19-cp313-cp313t-win_arm64.whl", hash = "sha256:c0761d7ae8d65773e01515ebb0b304df1bf37a0a79546caad9cbe79a42c12af7", size = 271643, upload-time = "2026-02-19T19:02:27.175Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e2/7ad4e76a6dddefc0d64dbe12a4d3ca3947a19ddc501f864a5df2a8222ddd/regex-2026.2.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:03d191a9bcf94d31af56d2575210cb0d0c6a054dbcad2ea9e00aa4c42903b919", size = 489306, upload-time = "2026-02-19T19:02:29.058Z" }, - { url = "https://files.pythonhosted.org/packages/14/95/ee1736135733afbcf1846c58671046f99c4d5170102a150ebb3dd8d701d9/regex-2026.2.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:516ee067c6c721d0d0bfb80a2004edbd060fffd07e456d4e1669e38fe82f922e", size = 291218, upload-time = "2026-02-19T19:02:31.083Z" }, - { url = "https://files.pythonhosted.org/packages/ef/08/180d1826c3d7065200a5168c6b993a44947395c7bb6e04b2c2a219c34225/regex-2026.2.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:997862c619994c4a356cb7c3592502cbd50c2ab98da5f61c5c871f10f22de7e5", size = 289097, upload-time = "2026-02-19T19:02:33.485Z" }, - { url = "https://files.pythonhosted.org/packages/28/93/0651924c390c5740f5f896723f8ddd946a6c63083a7d8647231c343912ff/regex-2026.2.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b9e1b8a7ebe2807cd7bbdf662510c8e43053a23262b9f46ad4fc2dfc9d204e", size = 799147, upload-time = "2026-02-19T19:02:35.669Z" }, - { url = "https://files.pythonhosted.org/packages/a7/00/2078bd8bcd37d58a756989adbfd9f1d0151b7ca4085a9c2a07e917fbac61/regex-2026.2.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c8fb3b19652e425ff24169dad3ee07f99afa7996caa9dfbb3a9106cd726f49a", size = 865239, upload-time = "2026-02-19T19:02:38.012Z" }, - { url = "https://files.pythonhosted.org/packages/2a/13/75195161ec16936b35a365fa8c1dd2ab29fd910dd2587765062b174d8cfc/regex-2026.2.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50f1ee9488dd7a9fda850ec7c68cad7a32fa49fd19733f5403a3f92b451dcf73", size = 911904, upload-time = "2026-02-19T19:02:40.737Z" }, - { url = "https://files.pythonhosted.org/packages/96/72/ac42f6012179343d1c4bd0ffee8c948d841cb32ea188d37e96d80527fcc9/regex-2026.2.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab780092b1424d13200aa5a62996e95f65ee3db8509be366437439cdc0af1a9f", size = 803518, upload-time = "2026-02-19T19:02:42.923Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d1/75a08e2269b007b9783f0f86aa64488e023141219cb5f14dc1e69cda56c6/regex-2026.2.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17648e1a88e72d88641b12635e70e6c71c5136ba14edba29bf8fc6834005a265", size = 775866, upload-time = "2026-02-19T19:02:45.189Z" }, - { url = "https://files.pythonhosted.org/packages/92/41/70e7d05faf6994c2ca7a9fcaa536da8f8e4031d45b0ec04b57040ede201f/regex-2026.2.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f914ae8c804c8a8a562fe216100bc156bfb51338c1f8d55fe32cf407774359a", size = 788224, upload-time = "2026-02-19T19:02:47.804Z" }, - { url = "https://files.pythonhosted.org/packages/c8/83/34a2dd601f9deb13c20545c674a55f4a05c90869ab73d985b74d639bac43/regex-2026.2.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c7e121a918bbee3f12ac300ce0a0d2f2c979cf208fb071ed8df5a6323281915c", size = 859682, upload-time = "2026-02-19T19:02:50.583Z" }, - { url = "https://files.pythonhosted.org/packages/8e/30/136db9a09a7f222d6e48b806f3730e7af6499a8cad9c72ac0d49d52c746e/regex-2026.2.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2fedd459c791da24914ecc474feecd94cf7845efb262ac3134fe27cbd7eda799", size = 764223, upload-time = "2026-02-19T19:02:52.777Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/bb947743c78a16df481fa0635c50aa1a439bb80b0e6dc24cd4e49c716679/regex-2026.2.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:ea8dfc99689240e61fb21b5fc2828f68b90abf7777d057b62d3166b7c1543c4c", size = 850101, upload-time = "2026-02-19T19:02:55.87Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/e3bfe6e97a99f7393665926be02fef772da7f8aa59e50bc3134e4262a032/regex-2026.2.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fff45852160960f29e184ec8a5be5ab4063cfd0b168d439d1fc4ac3744bf29e", size = 789904, upload-time = "2026-02-19T19:02:58.523Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/7e2be6f00cea59d08761b027ad237002e90cac74b1607200ebaa2ba3d586/regex-2026.2.19-cp314-cp314-win32.whl", hash = "sha256:5390b130cce14a7d1db226a3896273b7b35be10af35e69f1cca843b6e5d2bb2d", size = 271784, upload-time = "2026-02-19T19:03:00.418Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f6/639911530335773e7ec60bcaa519557b719586024c1d7eaad1daf87b646b/regex-2026.2.19-cp314-cp314-win_amd64.whl", hash = "sha256:e581f75d5c0b15669139ca1c2d3e23a65bb90e3c06ba9d9ea194c377c726a904", size = 280506, upload-time = "2026-02-19T19:03:02.302Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ec/2582b56b4e036d46bb9b5d74a18548439ffa16c11cf59076419174d80f48/regex-2026.2.19-cp314-cp314-win_arm64.whl", hash = "sha256:7187fdee1be0896c1499a991e9bf7c78e4b56b7863e7405d7bb687888ac10c4b", size = 273557, upload-time = "2026-02-19T19:03:04.836Z" }, - { url = "https://files.pythonhosted.org/packages/49/0b/f901cfeb4efd83e4f5c3e9f91a6de77e8e5ceb18555698aca3a27e215ed3/regex-2026.2.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5ec1d7c080832fdd4e150c6f5621fe674c70c63b3ae5a4454cebd7796263b175", size = 492196, upload-time = "2026-02-19T19:03:08.188Z" }, - { url = "https://files.pythonhosted.org/packages/94/0a/349b959e3da874e15eda853755567b4cde7e5309dbb1e07bfe910cfde452/regex-2026.2.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8457c1bc10ee9b29cdfd897ccda41dce6bde0e9abd514bcfef7bcd05e254d411", size = 292878, upload-time = "2026-02-19T19:03:10.272Z" }, - { url = "https://files.pythonhosted.org/packages/98/b0/9d81b3c2c5ddff428f8c506713737278979a2c476f6e3675a9c51da0c389/regex-2026.2.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cce8027010d1ffa3eb89a0b19621cdc78ae548ea2b49fea1f7bfb3ea77064c2b", size = 291235, upload-time = "2026-02-19T19:03:12.5Z" }, - { url = "https://files.pythonhosted.org/packages/04/e7/be7818df8691dbe9508c381ea2cc4c1153e4fdb1c4b06388abeaa93bd712/regex-2026.2.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11c138febb40546ff9e026dbbc41dc9fb8b29e61013fa5848ccfe045f5b23b83", size = 807893, upload-time = "2026-02-19T19:03:15.064Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b6/b898a8b983190cfa0276031c17beb73cfd1db07c03c8c37f606d80b655e2/regex-2026.2.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74ff212aa61532246bb3036b3dfea62233414b0154b8bc3676975da78383cac3", size = 873696, upload-time = "2026-02-19T19:03:17.848Z" }, - { url = "https://files.pythonhosted.org/packages/1a/98/126ba671d54f19080ec87cad228fb4f3cc387fff8c4a01cb4e93f4ff9d94/regex-2026.2.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d00c95a2b6bfeb3ea1cb68d1751b1dfce2b05adc2a72c488d77a780db06ab867", size = 915493, upload-time = "2026-02-19T19:03:20.343Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/550c84a1a1a7371867fe8be2bea7df55e797cbca4709974811410e195c5d/regex-2026.2.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311fcccb76af31be4c588d5a17f8f1a059ae8f4b097192896ebffc95612f223a", size = 813094, upload-time = "2026-02-19T19:03:23.287Z" }, - { url = "https://files.pythonhosted.org/packages/29/fb/ba221d2fc76a27b6b7d7a60f73a7a6a7bac21c6ba95616a08be2bcb434b0/regex-2026.2.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77cfd6b5e7c4e8bf7a39d243ea05882acf5e3c7002b0ef4756de6606893b0ecd", size = 781583, upload-time = "2026-02-19T19:03:26.872Z" }, - { url = "https://files.pythonhosted.org/packages/26/f1/af79231301297c9e962679efc04a31361b58dc62dec1fc0cb4b8dd95956a/regex-2026.2.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6380f29ff212ec922b6efb56100c089251940e0526a0d05aa7c2d9b571ddf2fe", size = 795875, upload-time = "2026-02-19T19:03:29.223Z" }, - { url = "https://files.pythonhosted.org/packages/a0/90/1e1d76cb0a2d0a4f38a039993e1c5cd971ae50435d751c5bae4f10e1c302/regex-2026.2.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:655f553a1fa3ab8a7fd570eca793408b8d26a80bfd89ed24d116baaf13a38969", size = 868916, upload-time = "2026-02-19T19:03:31.415Z" }, - { url = "https://files.pythonhosted.org/packages/9a/67/a1c01da76dbcfed690855a284c665cc0a370e7d02d1bd635cf9ff7dd74b8/regex-2026.2.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:015088b8558502f1f0bccd58754835aa154a7a5b0bd9d4c9b7b96ff4ae9ba876", size = 770386, upload-time = "2026-02-19T19:03:33.972Z" }, - { url = "https://files.pythonhosted.org/packages/49/6f/94842bf294f432ff3836bfd91032e2ecabea6d284227f12d1f935318c9c4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9e6693b8567a59459b5dda19104c4a4dbbd4a1c78833eacc758796f2cfef1854", size = 855007, upload-time = "2026-02-19T19:03:36.238Z" }, - { url = "https://files.pythonhosted.org/packages/ff/93/393cd203ca0d1d368f05ce12d2c7e91a324bc93c240db2e6d5ada05835f4/regex-2026.2.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4071209fd4376ab5ceec72ad3507e9d3517c59e38a889079b98916477a871868", size = 799863, upload-time = "2026-02-19T19:03:38.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/d9/35afda99bd92bf1a5831e55a4936d37ea4bed6e34c176a3c2238317faf4f/regex-2026.2.19-cp314-cp314t-win32.whl", hash = "sha256:2905ff4a97fad42f2d0834d8b1ea3c2f856ec209837e458d71a061a7d05f9f01", size = 274742, upload-time = "2026-02-19T19:03:40.804Z" }, - { url = "https://files.pythonhosted.org/packages/ae/42/7edc3344dcc87b698e9755f7f685d463852d481302539dae07135202d3ca/regex-2026.2.19-cp314-cp314t-win_amd64.whl", hash = "sha256:64128549b600987e0f335c2365879895f860a9161f283b14207c800a6ed623d3", size = 284443, upload-time = "2026-02-19T19:03:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/3a/45/affdf2d851b42adf3d13fc5b3b059372e9bd299371fd84cf5723c45871fa/regex-2026.2.19-cp314-cp314t-win_arm64.whl", hash = "sha256:a09ae430e94c049dc6957f6baa35ee3418a3a77f3c12b6e02883bd80a2b679b0", size = 274932, upload-time = "2026-02-19T19:03:45.488Z" }, + { url = "https://files.pythonhosted.org/packages/12/59/fd98f8fd54b3feaa76a855324c676c17668c5a1121ec91b7ec96b01bf865/regex-2026.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:74fa82dcc8143386c7c0392e18032009d1db715c25f4ba22d23dc2e04d02a20f", size = 489403, upload-time = "2026-04-03T20:52:39.742Z" }, + { url = "https://files.pythonhosted.org/packages/6c/64/d0f222f68e3579d50babf0e4fcc9c9639ef0587fecc00b15e1e46bfc32fa/regex-2026.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a85b620a388d6c9caa12189233109e236b3da3deffe4ff11b84ae84e218a274f", size = 291208, upload-time = "2026-04-03T20:52:42.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/7f/3fab9709b0b0060ba81a04b8a107b34147cd14b9c5551b772154d6505504/regex-2026.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2895506ebe32cc63eeed8f80e6eae453171cfccccab35b70dc3129abec35a5b8", size = 289214, upload-time = "2026-04-03T20:52:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/14/bc/f5dcf04fd462139dcd75495c02eee22032ef741cfa151386a39c3f5fc9b5/regex-2026.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6780f008ee81381c737634e75c24e5a6569cc883c4f8e37a37917ee79efcafd9", size = 785505, upload-time = "2026-04-03T20:52:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/37/36/8a906e216d5b4de7ec3788c1d589b45db40c1c9580cd7b326835cfc976d4/regex-2026.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88e9b048345c613f253bea4645b2fe7e579782b82cac99b1daad81e29cc2ed8e", size = 852129, upload-time = "2026-04-03T20:52:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/a5/bb/bad2d79be0917a6ef31f5e0f161d9265cb56fd90a3ae1d2e8d991882a48b/regex-2026.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:be061028481186ba62a0f4c5f1cc1e3d5ab8bce70c89236ebe01023883bc903b", size = 899578, upload-time = "2026-04-03T20:52:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/7cd0ceb58cd99c70806241636640ae15b4a3fe62e22e9b99afa67a0d7965/regex-2026.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d2228c02b368d69b724c36e96d3d1da721561fb9cc7faa373d7bf65e07d75cb5", size = 793634, upload-time = "2026-04-03T20:52:53Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fb/c58e3ea40ed183806ccbac05c29a3e8c2f88c1d3a66ed27860d5cad7c62d/regex-2026.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0540e5b733618a2f84e9cb3e812c8afa82e151ca8e19cf6c4e95c5a65198236f", size = 786210, upload-time = "2026-04-03T20:52:54.713Z" }, + { url = "https://files.pythonhosted.org/packages/54/a9/53790fc7a6c948a7be2bc7214fd9cabdd0d1ba561b0f401c91f4ff0357f0/regex-2026.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf9b1b2e692d4877880388934ac746c99552ce6bf40792a767fd42c8c99f136d", size = 769930, upload-time = "2026-04-03T20:52:56.825Z" }, + { url = "https://files.pythonhosted.org/packages/e3/3c/29ca44729191c79f5476538cd0fa04fa2553b3c45508519ecea4c7afa8f6/regex-2026.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:011bb48bffc1b46553ac704c975b3348717f4e4aa7a67522b51906f99da1820c", size = 774892, upload-time = "2026-04-03T20:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/6ae74ef8a4cfead341c367e4eed45f71fb1aaba35827a775eed4f1ba4f74/regex-2026.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8512fcdb43f1bf18582698a478b5ab73f9c1667a5b7548761329ef410cd0a760", size = 848816, upload-time = "2026-04-03T20:53:00.684Z" }, + { url = "https://files.pythonhosted.org/packages/53/9a/f7f2c1c6b610d7c6de1c3dc5951effd92c324b1fde761af2044b4721020f/regex-2026.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:867bddc63109a0276f5a31999e4c8e0eb7bbbad7d6166e28d969a2c1afeb97f9", size = 758363, upload-time = "2026-04-03T20:53:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/dd/55/e5386d393bbf8b43c8b084703a46d635e7b2bdc6e0f5909a2619ea1125f1/regex-2026.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1b9a00b83f3a40e09859c78920571dcb83293c8004079653dd22ec14bbfa98c7", size = 837122, upload-time = "2026-04-03T20:53:03.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/cc78710ea2e60b10bacfcc9beb18c67514200ab03597b3b2b319995785c2/regex-2026.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e355be718caf838aa089870259cf1776dc2a4aa980514af9d02c59544d9a8b22", size = 782140, upload-time = "2026-04-03T20:53:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5f/c7bcba41529105d6c2ca7080ecab7184cd00bee2e1ad1fdea80e618704ea/regex-2026.4.4-cp310-cp310-win32.whl", hash = "sha256:33bfda9684646d323414df7abe5692c61d297dbb0530b28ec66442e768813c59", size = 266225, upload-time = "2026-04-03T20:53:07.342Z" }, + { url = "https://files.pythonhosted.org/packages/eb/26/a745729c2c49354ec4f4bce168f29da932ca01b4758227686cc16c7dde1b/regex-2026.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:0709f22a56798457ae317bcce42aacee33c680068a8f14097430d9f9ba364bee", size = 278393, upload-time = "2026-04-03T20:53:08.65Z" }, + { url = "https://files.pythonhosted.org/packages/87/8b/4327eeb9dbb4b098ebecaf02e9f82b79b6077beeb54c43d9a0660cf7c44c/regex-2026.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:ee9627de8587c1a22201cb16d0296ab92b4df5cdcb5349f4e9744d61db7c7c98", size = 270470, upload-time = "2026-04-03T20:53:10.018Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7a/617356cbecdb452812a5d42f720d6d5096b360d4a4c1073af700ea140ad2/regex-2026.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4c36a85b00fadb85db9d9e90144af0a980e1a3d2ef9cd0f8a5bef88054657c6", size = 489415, upload-time = "2026-04-03T20:53:11.645Z" }, + { url = "https://files.pythonhosted.org/packages/20/e6/bf057227144d02e3ba758b66649e87531d744dda5f3254f48660f18ae9d8/regex-2026.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dcb5453ecf9cd58b562967badd1edbf092b0588a3af9e32ee3d05c985077ce87", size = 291205, upload-time = "2026-04-03T20:53:13.289Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3b/637181b787dd1a820ba1c712cee2b4144cd84a32dc776ca067b12b2d70c8/regex-2026.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6aa809ed4dc3706cc38594d67e641601bd2f36d5555b2780ff074edfcb136cf8", size = 289225, upload-time = "2026-04-03T20:53:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/05/21/bac05d806ed02cd4b39d9c8e5b5f9a2998c94c3a351b7792e80671fa5315/regex-2026.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33424f5188a7db12958246a54f59a435b6cb62c5cf9c8d71f7cc49475a5fdada", size = 792434, upload-time = "2026-04-03T20:53:17.414Z" }, + { url = "https://files.pythonhosted.org/packages/d9/17/c65d1d8ae90b772d5758eb4014e1e011bb2db353fc4455432e6cc9100df7/regex-2026.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d346fccdde28abba117cc9edc696b9518c3307fbfcb689e549d9b5979018c6d", size = 861730, upload-time = "2026-04-03T20:53:18.903Z" }, + { url = "https://files.pythonhosted.org/packages/ad/64/933321aa082a2c6ee2785f22776143ba89840189c20d3b6b1d12b6aae16b/regex-2026.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:415a994b536440f5011aa77e50a4274d15da3245e876e5c7f19da349caaedd87", size = 906495, upload-time = "2026-04-03T20:53:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/01/ea/4c8d306e9c36ac22417336b1e02e7b358152c34dc379673f2d331143725f/regex-2026.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21e5eb86179b4c67b5759d452ea7c48eb135cd93308e7a260aa489ed2eb423a4", size = 799810, upload-time = "2026-04-03T20:53:22.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/ce/7605048f00e1379eba89d610c7d644d8f695dc9b26d3b6ecfa3132b872ff/regex-2026.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:312ec9dd1ae7d96abd8c5a36a552b2139931914407d26fba723f9e53c8186f86", size = 774242, upload-time = "2026-04-03T20:53:25.015Z" }, + { url = "https://files.pythonhosted.org/packages/e9/77/283e0d5023fde22cd9e86190d6d9beb21590a452b195ffe00274de470691/regex-2026.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0d2b28aa1354c7cd7f71b7658c4326f7facac106edd7f40eda984424229fd59", size = 781257, upload-time = "2026-04-03T20:53:26.918Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fb/7f3b772be101373c8626ed34c5d727dcbb8abd42a7b1219bc25fd9a3cc04/regex-2026.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:349d7310eddff40429a099c08d995c6d4a4bfaf3ff40bd3b5e5cb5a5a3c7d453", size = 854490, upload-time = "2026-04-03T20:53:29.065Z" }, + { url = "https://files.pythonhosted.org/packages/85/30/56547b80f34f4dd2986e1cdd63b1712932f63b6c4ce2f79c50a6cd79d1c2/regex-2026.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e7ab63e9fe45a9ec3417509e18116b367e89c9ceb6219222a3396fa30b147f80", size = 763544, upload-time = "2026-04-03T20:53:30.917Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2f/ce060fdfea8eff34a8997603532e44cdb7d1f35e3bc253612a8707a90538/regex-2026.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fe896e07a5a2462308297e515c0054e9ec2dd18dfdc9427b19900b37dfe6f40b", size = 844442, upload-time = "2026-04-03T20:53:32.463Z" }, + { url = "https://files.pythonhosted.org/packages/e5/44/810cb113096a1dacbe82789fbfab2823f79d19b7f1271acecb7009ba9b88/regex-2026.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb59c65069498dbae3c0ef07bbe224e1eaa079825a437fb47a479f0af11f774f", size = 789162, upload-time = "2026-04-03T20:53:34.039Z" }, + { url = "https://files.pythonhosted.org/packages/20/96/9647dd7f2ecf6d9ce1fb04dfdb66910d094e10d8fe53e9c15096d8aa0bd2/regex-2026.4.4-cp311-cp311-win32.whl", hash = "sha256:2a5d273181b560ef8397c8825f2b9d57013de744da9e8257b8467e5da8599351", size = 266227, upload-time = "2026-04-03T20:53:35.601Z" }, + { url = "https://files.pythonhosted.org/packages/33/80/74e13262460530c3097ff343a17de9a34d040a5dc4de9cf3a8241faab51c/regex-2026.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:9542ccc1e689e752594309444081582f7be2fdb2df75acafea8a075108566735", size = 278399, upload-time = "2026-04-03T20:53:37.021Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/39f19f47f19dcefa3403f09d13562ca1c0fd07ab54db2bc03148f3f6b46a/regex-2026.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:b5f9fb784824a042be3455b53d0b112655686fdb7a91f88f095f3fee1e2a2a54", size = 270473, upload-time = "2026-04-03T20:53:38.633Z" }, + { url = "https://files.pythonhosted.org/packages/e5/28/b972a4d3df61e1d7bcf1b59fdb3cddef22f88b6be43f161bb41ebc0e4081/regex-2026.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c07ab8794fa929e58d97a0e1796b8b76f70943fa39df225ac9964615cf1f9d52", size = 490434, upload-time = "2026-04-03T20:53:40.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/20/30041446cf6dc3e0eab344fc62770e84c23b6b68a3b657821f9f80cb69b4/regex-2026.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c785939dc023a1ce4ec09599c032cc9933d258a998d16ca6f2b596c010940eb", size = 292061, upload-time = "2026-04-03T20:53:41.862Z" }, + { url = "https://files.pythonhosted.org/packages/62/c8/3baa06d75c98c46d4cc4262b71fd2edb9062b5665e868bca57859dadf93a/regex-2026.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b1ce5c81c9114f1ce2f9288a51a8fd3aeea33a0cc440c415bf02da323aa0a76", size = 289628, upload-time = "2026-04-03T20:53:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/31/87/3accf55634caad8c0acab23f5135ef7d4a21c39f28c55c816ae012931408/regex-2026.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:760ef21c17d8e6a4fe8cf406a97cf2806a4df93416ccc82fc98d25b1c20425be", size = 796651, upload-time = "2026-04-03T20:53:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/aaa2c83f34efedbf06f61cb1942c25f6cf1ee3b200f832c4d05f28306c2e/regex-2026.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7088fcdcb604a4417c208e2169715800d28838fefd7455fbe40416231d1d47c1", size = 865916, upload-time = "2026-04-03T20:53:47.064Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f6/8c6924c865124643e8f37823eca845dc27ac509b2ee58123685e71cd0279/regex-2026.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07edca1ba687998968f7db5bc355288d0c6505caa7374f013d27356d93976d13", size = 912287, upload-time = "2026-04-03T20:53:49.422Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/a9f6f81013e0deaf559b25711623864970fe6a098314e374ccb1540a4152/regex-2026.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f657a7c1c6ec51b5e0ba97c9817d06b84ea5fa8d82e43b9405de0defdc2b9", size = 801126, upload-time = "2026-04-03T20:53:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/71/61/3a0cc8af2dc0c8deb48e644dd2521f173f7e6513c6e195aad9aa8dd77ac5/regex-2026.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b69102a743e7569ebee67e634a69c4cb7e59d6fa2e1aa7d3bdbf3f61435f62d", size = 776788, upload-time = "2026-04-03T20:53:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/64/0b/8bb9cbf21ef7dee58e49b0fdb066a7aded146c823202e16494a36777594f/regex-2026.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dac006c8b6dda72d86ea3d1333d45147de79a3a3f26f10c1cf9287ca4ca0ac3", size = 785184, upload-time = "2026-04-03T20:53:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/99/c2/d3e80e8137b25ee06c92627de4e4d98b94830e02b3e6f81f3d2e3f504cf5/regex-2026.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:50a766ee2010d504554bfb5f578ed2e066898aa26411d57e6296230627cdefa0", size = 859913, upload-time = "2026-04-03T20:53:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/9d5d876157d969c804622456ef250017ac7a8f83e0e14f903b9e6df5ce95/regex-2026.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9e2f5217648f68e3028c823df58663587c1507a5ba8419f4fdfc8a461be76043", size = 765732, upload-time = "2026-04-03T20:53:59.428Z" }, + { url = "https://files.pythonhosted.org/packages/82/80/b568935b4421388561c8ed42aff77247285d3ae3bb2a6ca22af63bae805e/regex-2026.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39d8de85a08e32632974151ba59c6e9140646dcc36c80423962b1c5c0a92e244", size = 852152, upload-time = "2026-04-03T20:54:01.505Z" }, + { url = "https://files.pythonhosted.org/packages/39/29/f0f81217e21cd998245da047405366385d5c6072048038a3d33b37a79dc0/regex-2026.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:55d9304e0e7178dfb1e106c33edf834097ddf4a890e2f676f6c5118f84390f73", size = 789076, upload-time = "2026-04-03T20:54:03.323Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/1d957a61976ab9d4e767dd4f9d04b66cc0c41c5e36cf40e2d43688b5ae6f/regex-2026.4.4-cp312-cp312-win32.whl", hash = "sha256:04bb679bc0bde8a7bfb71e991493d47314e7b98380b083df2447cda4b6edb60f", size = 266700, upload-time = "2026-04-03T20:54:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/bf575d396aeb58ea13b06ef2adf624f65b70fafef6950a80fc3da9cae3bc/regex-2026.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:db0ac18435a40a2543dbb3d21e161a6c78e33e8159bd2e009343d224bb03bb1b", size = 277768, upload-time = "2026-04-03T20:54:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/c9/27/049df16ec6a6828ccd72add3c7f54b4df029669bea8e9817df6fff58be90/regex-2026.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:4ce255cc05c1947a12989c6db801c96461947adb7a59990f1360b5983fab4983", size = 270568, upload-time = "2026-04-03T20:54:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/c4373bc5f31f2cf4b66f9b7c31005bd87fe66f0dce17701f7db4ee79ee29/regex-2026.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:62f5519042c101762509b1d717b45a69c0139d60414b3c604b81328c01bd1943", size = 490273, upload-time = "2026-04-03T20:54:11.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/f8/fe62afbcc3cf4ad4ac9adeaafd98aa747869ae12d3e8e2ac293d0593c435/regex-2026.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3790ba9fb5dd76715a7afe34dbe603ba03f8820764b1dc929dd08106214ed031", size = 291954, upload-time = "2026-04-03T20:54:13.412Z" }, + { url = "https://files.pythonhosted.org/packages/5a/92/4712b9fe6a33d232eeb1c189484b80c6c4b8422b90e766e1195d6e758207/regex-2026.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fae3c6e795d7678963f2170152b0d892cf6aee9ee8afc8c45e6be38d5107fe7", size = 289487, upload-time = "2026-04-03T20:54:15.824Z" }, + { url = "https://files.pythonhosted.org/packages/88/2c/f83b93f85e01168f1070f045a42d4c937b69fdb8dd7ae82d307253f7e36e/regex-2026.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:298c3ec2d53225b3bf91142eb9691025bab610e0c0c51592dde149db679b3d17", size = 796646, upload-time = "2026-04-03T20:54:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/df/55/61a2e17bf0c4dc57e11caf8dd11771280d8aaa361785f9e3bc40d653f4a7/regex-2026.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9638791082eaf5b3ac112c587518ee78e083a11c4b28012d8fe2a0f536dfb17", size = 865904, upload-time = "2026-04-03T20:54:20.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/32/1ac8ed1b5a346b5993a3d256abe0a0f03b0b73c8cc88d928537368ac65b6/regex-2026.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae3e764bd4c5ff55035dc82a8d49acceb42a5298edf6eb2fc4d328ee5dd7afae", size = 912304, upload-time = "2026-04-03T20:54:22.403Z" }, + { url = "https://files.pythonhosted.org/packages/26/47/2ee5c613ab546f0eddebf9905d23e07beb933416b1246c2d8791d01979b4/regex-2026.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffa81f81b80047ba89a3c69ae6a0f78d06f4a42ce5126b0eb2a0a10ad44e0b2e", size = 801126, upload-time = "2026-04-03T20:54:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/41dacd129ca9fd20bd7d02f83e0fad83e034ac8a084ec369c90f55ef37e2/regex-2026.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f56ebf9d70305307a707911b88469213630aba821e77de7d603f9d2f0730687d", size = 776772, upload-time = "2026-04-03T20:54:26.319Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5af0b588174cb5f46041fa7dd64d3fd5cd2fe51f18766703d1edc387f324/regex-2026.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:773d1dfd652bbffb09336abf890bfd64785c7463716bf766d0eb3bc19c8b7f27", size = 785228, upload-time = "2026-04-03T20:54:28.387Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3b/f5a72b7045bd59575fc33bf1345f156fcfd5a8484aea6ad84b12c5a82114/regex-2026.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d51d20befd5275d092cdffba57ded05f3c436317ee56466c8928ac32d960edaf", size = 860032, upload-time = "2026-04-03T20:54:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/39/a4/72a317003d6fcd7a573584a85f59f525dfe8f67e355ca74eb6b53d66a5e2/regex-2026.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0a51cdb3c1e9161154f976cb2bef9894bc063ac82f31b733087ffb8e880137d0", size = 765714, upload-time = "2026-04-03T20:54:32.789Z" }, + { url = "https://files.pythonhosted.org/packages/25/1e/5672e16f34dbbcb2560cc7e6a2fbb26dfa8b270711e730101da4423d3973/regex-2026.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae5266a82596114e41fb5302140e9630204c1b5f325c770bec654b95dd54b0aa", size = 852078, upload-time = "2026-04-03T20:54:34.546Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/c813f0af7c6cc7ed7b9558bac2e5120b60ad0fa48f813e4d4bd55446f214/regex-2026.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c882cd92ec68585e9c1cf36c447ec846c0d94edd706fe59e0c198e65822fd23b", size = 789181, upload-time = "2026-04-03T20:54:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6d/a344608d1adbd2a95090ddd906cec09a11be0e6517e878d02a5123e0917f/regex-2026.4.4-cp313-cp313-win32.whl", hash = "sha256:05568c4fbf3cb4fa9e28e3af198c40d3237cf6041608a9022285fe567ec3ad62", size = 266690, upload-time = "2026-04-03T20:54:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/54049f89b46235ca6f45cd6c88668a7050e77d4a15555e47dd40fde75263/regex-2026.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:3384df51ed52db0bea967e21458ab0a414f67cdddfd94401688274e55147bb81", size = 277733, upload-time = "2026-04-03T20:54:40.11Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/61366a8e20f4d43fb597708cac7f0e2baadb491ecc9549b4980b2be27d16/regex-2026.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:acd38177bd2c8e69a411d6521760806042e244d0ef94e2dd03ecdaa8a3c99427", size = 270565, upload-time = "2026-04-03T20:54:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1e/3a2b9672433bef02f5d39aa1143ca2c08f311c1d041c464a42be9ae648dc/regex-2026.4.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f94a11a9d05afcfcfa640e096319720a19cc0c9f7768e1a61fceee6a3afc6c7c", size = 494126, upload-time = "2026-04-03T20:54:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/4e/4b/c132a4f4fe18ad3340d89fcb56235132b69559136036b845be3c073142ed/regex-2026.4.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:36bcb9d6d1307ab629edc553775baada2aefa5c50ccc0215fbfd2afcfff43141", size = 293882, upload-time = "2026-04-03T20:54:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5f/eaa38092ce7a023656280f2341dbbd4ad5f05d780a70abba7bb4f4bea54c/regex-2026.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261c015b3e2ed0919157046d768774ecde57f03d8fa4ba78d29793447f70e717", size = 292334, upload-time = "2026-04-03T20:54:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f6/dd38146af1392dac33db7074ab331cec23cced3759167735c42c5460a243/regex-2026.4.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c228cf65b4a54583763645dcd73819b3b381ca8b4bb1b349dee1c135f4112c07", size = 811691, upload-time = "2026-04-03T20:54:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/dc54c2e69f5eeec50601054998ec3690d5344277e782bd717e49867c1d29/regex-2026.4.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd2630faeb6876fb0c287f664d93ddce4d50cd46c6e88e60378c05c9047e08ca", size = 871227, upload-time = "2026-04-03T20:54:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/cb16bd5dc61621e27df919a4449bbb7e5a1034c34d307e0a706e9cc0f3e3/regex-2026.4.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a50ab11b7779b849472337191f3a043e27e17f71555f98d0092fa6d73364520", size = 917435, upload-time = "2026-04-03T20:54:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/5c/71/8b260897f22996b666edd9402861668f45a2ca259f665ac029e6104a2d7d/regex-2026.4.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0734f63afe785138549fbe822a8cfeaccd1bae814c5057cc0ed5b9f2de4fc883", size = 816358, upload-time = "2026-04-03T20:54:54.884Z" }, + { url = "https://files.pythonhosted.org/packages/1c/60/775f7f72a510ef238254906c2f3d737fc80b16ca85f07d20e318d2eea894/regex-2026.4.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4ee50606cb1967db7e523224e05f32089101945f859928e65657a2cbb3d278b", size = 785549, upload-time = "2026-04-03T20:54:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/58/42/34d289b3627c03cf381e44da534a0021664188fa49ba41513da0b4ec6776/regex-2026.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6c1818f37be3ca02dcb76d63f2c7aaba4b0dc171b579796c6fbe00148dfec6b1", size = 801364, upload-time = "2026-04-03T20:54:58.981Z" }, + { url = "https://files.pythonhosted.org/packages/fc/20/f6ecf319b382a8f1ab529e898b222c3f30600fcede7834733c26279e7465/regex-2026.4.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f5bfc2741d150d0be3e4a0401a5c22b06e60acb9aa4daa46d9e79a6dcd0f135b", size = 866221, upload-time = "2026-04-03T20:55:00.88Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/9f16d3609d549bd96d7a0b2aee1625d7512ba6a03efc01652149ef88e74d/regex-2026.4.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:504ffa8a03609a087cad81277a629b6ce884b51a24bd388a7980ad61748618ff", size = 772530, upload-time = "2026-04-03T20:55:03.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f6/aa9768bc96a4c361ac96419fbaf2dcdc33970bb813df3ba9b09d5d7b6d96/regex-2026.4.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70aadc6ff12e4b444586e57fc30771f86253f9f0045b29016b9605b4be5f7dfb", size = 856989, upload-time = "2026-04-03T20:55:05.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b4/c671db3556be2473ae3e4bb7a297c518d281452871501221251ea4ecba57/regex-2026.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f4f83781191007b6ef43b03debc35435f10cad9b96e16d147efe84a1d48bdde4", size = 803241, upload-time = "2026-04-03T20:55:07.162Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/83e3b1d89fa4f6e5a1bc97b4abd4a9a97b3c1ac7854164f694f5f0ba98a0/regex-2026.4.4-cp313-cp313t-win32.whl", hash = "sha256:e014a797de43d1847df957c0a2a8e861d1c17547ee08467d1db2c370b7568baa", size = 269921, upload-time = "2026-04-03T20:55:09.62Z" }, + { url = "https://files.pythonhosted.org/packages/28/07/077c387121f42cdb4d92b1301133c0d93b5709d096d1669ab847dda9fe2e/regex-2026.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:b15b88b0d52b179712632832c1d6e58e5774f93717849a41096880442da41ab0", size = 281240, upload-time = "2026-04-03T20:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/9d/22/ead4a4abc7c59a4d882662aa292ca02c8b617f30b6e163bc1728879e9353/regex-2026.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:586b89cdadf7d67bf86ae3342a4dcd2b8d70a832d90c18a0ae955105caf34dbe", size = 272440, upload-time = "2026-04-03T20:55:13.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" }, + { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" }, + { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" }, + { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" }, + { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" }, + { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" }, + { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" }, + { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" }, + { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" }, + { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" }, ] [[package]] @@ -330,56 +330,56 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" +version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, - { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, - { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, - { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, - { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, - { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, - { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, - { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, - { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, - { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, - { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, - { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, - { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] diff --git a/vite.config.ts b/vite.config.ts index cc446f6558a..53717813a10 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,12 @@ import {build, defineConfig} from 'vite'; import vuePlugin from '@vitejs/plugin-vue'; import {stringPlugin} from 'vite-string-plugin'; +import {licensePlugin, wrap} from 'rolldown-license-plugin'; import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs'; import path, {basename, join, parse} from 'node:path'; import {env} from 'node:process'; import tailwindcss from 'tailwindcss'; import tailwindConfig from './tailwind.config.ts'; -import wrapAnsi from 'wrap-ansi'; -import licensePlugin from 'rollup-plugin-license'; import type {InlineConfig, Plugin, Rolldown} from 'vite'; import {camelize} from 'vue'; @@ -39,15 +38,25 @@ const webComponents = new Set([ 'text-expander', ]); -function formatLicenseText(licenseText: string) { - return wrapAnsi(licenseText || '', 80).trim(); +function failOnWarningsPlugin(): Rolldown.Plugin { + let warningCount = 0; + return { + name: 'fail-on-warnings', + onLog(level) { + if (level === 'warn') warningCount++; + }, + buildEnd() { + if (!warningCount) return; + throw new Error(`${warningCount} warnings present`); + }, + }; } const commonRolldownOptions: Rolldown.RolldownOptions = { checks: { - eval: false, // htmx needs eval pluginTimings: false, }, + ...(env.CI ? {plugins: [failOnWarningsPlugin()]} : {}), }; function commonViteOpts({build, ...other}: InlineConfig): InlineConfig { @@ -92,7 +101,7 @@ function iifeBuildOpts({sourceFileName, write}: {sourceFileName: string, write?: } // Build iife.js as a blocking IIFE bundle. In dev mode, serves it from memory -// and rebuilds on file changes. In prod mode, writes to disk during closeBundle. +// and rebuilds on file changes. In prod mode, writes to disk and updates "manifest.json". function iifePlugin(sourceFileName: string): Plugin { let iifeCode = '', iifeMap = ''; const iifeModules = new Set(); @@ -149,7 +158,7 @@ function iifePlugin(sourceFileName: string): Plugin { } }); }, - async closeBundle() { + async writeBundle() { for (const file of globSync(`js/${sourceBaseName}.*.js*`, {cwd: outDir})) unlinkSync(join(outDir, file)); const result = await build(iifeBuildOpts({sourceFileName})); @@ -171,6 +180,7 @@ function reducedSourcemapPlugin(): Plugin { 'js/index.', 'js/iife.', 'js/swagger.', + 'js/external-render-frontend.', 'js/external-render-helper.', 'js/eventsource.sharedworker.', ]; @@ -257,8 +267,10 @@ export default defineConfig(commonViteOpts({ manifest: true, rolldownOptions: { input: { + // FIXME: INCORRECT-VITE-MANIFEST-PARSER: the "css importing" logic in backend is wrong index: join(import.meta.dirname, 'web_src/js/index.ts'), swagger: join(import.meta.dirname, 'web_src/js/swagger.ts'), + 'external-render-frontend': join(import.meta.dirname, 'web_src/js/external-render-frontend.ts'), 'eventsource.sharedworker': join(import.meta.dirname, 'web_src/js/eventsource.sharedworker.ts'), devtest: join(import.meta.dirname, 'web_src/css/devtest.css'), ...themes, @@ -314,33 +326,29 @@ export default defineConfig(commonViteOpts({ }, }), isProduction ? licensePlugin({ - thirdParty: { - output: { - file: join(import.meta.dirname, 'public/assets/licenses.txt'), - template(deps) { - const line = '-'.repeat(80); - const goJson = readFileSync(join(import.meta.dirname, 'assets/go-licenses.json'), 'utf8'); - const goModules = JSON.parse(goJson).map(({name, licenseText}: {name: string, licenseText: string}) => { - return {name, body: formatLicenseText(licenseText)}; - }); - const jsModules = deps.map((dep) => { - return {name: dep.name, version: dep.version, body: formatLicenseText(dep.licenseText ?? '')}; - }); - const modules = [...goModules, ...jsModules].sort((a, b) => a.name.localeCompare(b.name)); - return modules.map(({name, version, body}: {name: string, version?: string, body: string}) => { - const title = version ? `${name}@${version}` : name; - return `${line}\n${title}\n${line}\n${body}`; - }).join('\n'); - }, - }, - allow(dependency) { - if (dependency.name === 'khroma') return true; // MIT: https://github.com/fabiospampinato/khroma/pull/33 - return /(Apache-2\.0|0BSD|BSD-2-Clause|BSD-3-Clause|MIT|ISC|CPAL-1\.0|Unlicense|EPL-1\.0|EPL-2\.0)/.test(dependency.license ?? ''); - }, + done(deps, context) { + const line = '-'.repeat(80); + const goLicenses = JSON.parse(readFileSync(join(import.meta.dirname, 'assets/go-licenses.json'), 'utf8')); + const combined: Record = {}; + for (const {name, licenseText} of goLicenses) { + combined[name] = wrap(licenseText || '', 80).trim(); + } + for (const {name, version, licenseText} of deps) { + combined[`${name}@${version}`] = wrap(licenseText, 80).trim(); + } + const content = Object.entries(combined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([title, body]) => `${line}\n${title}\n${line}\n${body}`).join('\n'); + context.emitFile({type: 'asset', fileName: 'licenses.txt', source: content}); + }, + match: /^((UN)?LICEN(S|C)E|COPYING).*$/i, // also defined in build/generate-go-licenses.go + allow(dep) { + if (dep.name === 'khroma') return true; // MIT: https://github.com/fabiospampinato/khroma/pull/33 + return /(Apache-2\.0|0BSD|BSD-2-Clause|BSD-3-Clause|MIT|ISC|CPAL-1\.0|Unlicense|EPL-1\.0|EPL-2\.0)/.test(dep.license); }, }) : { name: 'dev-licenses-stub', - closeBundle() { + configureServer() { writeFileSync(join(outDir, 'licenses.txt'), 'Licenses are disabled during development'); }, }, diff --git a/web_src/css/actions.css b/web_src/css/actions.css index c43ebe21a05..14cf65f273d 100644 --- a/web_src/css/actions.css +++ b/web_src/css/actions.css @@ -6,14 +6,6 @@ overflow-x: auto; } -.runner-container .runner-new-text { - color: var(--color-white); -} - -.runner-container #runner-new:hover .runner-new-text { - color: var(--color-white) !important; -} - .runner-container .task-status-success { background-color: var(--color-green); color: var(--color-white); diff --git a/web_src/css/admin.css b/web_src/css/admin.css index d84aa7e811b..a07dcc9ed78 100644 --- a/web_src/css/admin.css +++ b/web_src/css/admin.css @@ -55,5 +55,5 @@ padding: 1em 1.5em; border: 1px solid var(--color-info-border); background: var(--color-info-bg); - color: var(--color-info-text); + color: var(--color-text); } diff --git a/web_src/css/base.css b/web_src/css/base.css index a8d9dea2a25..8da67a8f2e2 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -431,8 +431,9 @@ img.ui.avatar, margin-top: calc(var(--page-spacing) - 1rem); } -.ui .message.flash-message { - text-align: center; +.ui.message.flash-message pre { + white-space: pre-line; + margin: 0; } .ui .header > i + .content { @@ -500,10 +501,10 @@ img.ui.avatar, } blockquote.attention-note { - border-left-color: var(--color-blue-dark-1); + border-left-color: var(--color-info-text); } strong.attention-note, svg.attention-note { - color: var(--color-blue-dark-1); + color: var(--color-info-text); } blockquote.attention-tip { @@ -514,10 +515,10 @@ strong.attention-tip, svg.attention-tip { } blockquote.attention-important { - border-left-color: var(--color-violet-dark-1); + border-left-color: var(--color-priority-text); } strong.attention-important, svg.attention-important { - color: var(--color-violet-dark-1); + color: var(--color-priority-text); } blockquote.attention-warning { @@ -528,10 +529,10 @@ strong.attention-warning, svg.attention-warning { } blockquote.attention-caution { - border-left-color: var(--color-red-dark-1); + border-left-color: var(--color-error-text); } strong.attention-caution, svg.attention-caution { - color: var(--color-red-dark-1); + color: var(--color-error-text); } /* FIXME: this is a longstanding dirty patch since 2015, it only makes the pages more messy and shouldn't be used */ @@ -643,10 +644,6 @@ overflow-menu .ui.label { color: var(--color-primary-contrast); } -.archived-icon { - color: var(--color-secondary-dark-2) !important; -} - .oauth2-authorize-application-box { margin-top: 3em !important; } @@ -670,10 +667,6 @@ overflow-menu .ui.label { min-width: 50px; } -.lines-num span.bottom-line::after { - border-bottom: 1px solid var(--color-secondary); -} - .lines-num span::after { content: attr(data-line-number); line-height: var(--line-height-code) !important; @@ -783,22 +776,23 @@ tr.top-line-blame:first-of-type { border-top: none; /* merge code lines belonging to the same commit into one block */ } -.lines-code .bottom-line, -.lines-commit .bottom-line { - border-bottom: 1px solid var(--color-secondary); -} - .migrate .svg.gitea-git { color: var(--color-git); } .color-icon { display: inline-block; + flex-shrink: 0; border-radius: var(--border-radius-full); height: 14px; width: 14px; } +.icon-size-8 { + width: 8px; + height: 8px; +} + .rss-icon { display: inline-flex; color: var(--color-text-light-1); @@ -878,6 +872,23 @@ table th[data-sortt-desc] .svg { align-items: stretch; } +/* can be used to replace "ui relaxed list" or "tw-flex tw-flex-col tw-gap-xxx" when we need more flexible layout */ +.flex-relaxed-list { + display: flex; + flex-direction: column; + gap: var(--gap-block); +} + +/* this is useful to make a left-right (e.g.: title .... operations) layout with default gap, and it wrap for small widths */ +.flex-left-right { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: var(--gap-block); + min-width: 0; +} + .ui.list.flex-items-block > .item, .ui.vertical.menu.flex-items-block > .item, .ui.form .field > label.flex-text-block, /* override fomantic "block" style */ @@ -889,6 +900,7 @@ table th[data-sortt-desc] .svg { min-width: 0; } +.flex-left-right > .ui.button, .flex-text-block > .ui.button, .flex-text-inline > .ui.button { margin: 0; /* fomantic buttons have default margin, when we use them in a flex container with gap, we do not need these margins */ diff --git a/web_src/css/devtest.css b/web_src/css/devtest.css index a7b00e1e561..c344d99058b 100644 --- a/web_src/css/devtest.css +++ b/web_src/css/devtest.css @@ -1,3 +1,8 @@ +h1, h2 { + margin: 0; + padding: 10px 0; +} + .button-sample-groups { margin: 0; padding: 0; } @@ -10,7 +15,6 @@ margin-bottom: 5px; } -h1, h2 { - margin: 0; - padding: 10px 0; +.fetch-action-demo-forms .form-fetch-action { + border: 1px red dashed; /* show the border for demo purpose */ } diff --git a/web_src/css/features/heatmap.css b/web_src/css/features/heatmap.css index e40adf1fe48..a2d8c4ea590 100644 --- a/web_src/css/features/heatmap.css +++ b/web_src/css/features/heatmap.css @@ -32,26 +32,29 @@ fill: currentcolor !important; } -/* root legend */ -#user-heatmap .vch__container > .vch__legend { +#user-heatmap .heatmap-footer { display: flex; font-size: 11px; justify-content: space-between; } -/* for the "Less" and "More" legend */ -#user-heatmap .vch__legend .vch__legend { +/* "Less [colors] More" scale */ +#user-heatmap .heatmap-legend { display: flex; align-items: center; justify-content: right; } -#user-heatmap .vch__legend .vch__legend div:first-child, -#user-heatmap .vch__legend .vch__legend div:last-child { +#user-heatmap .heatmap-legend-svg { + margin-right: -12px; +} + +#user-heatmap .heatmap-legend > div:first-child, +#user-heatmap .heatmap-legend > div:last-child { display: inline-block; padding: 0 5px; } -#user-heatmap .vch__day__square:hover { +#user-heatmap .heatmap-day:hover { outline: 1.5px solid var(--color-text); } diff --git a/web_src/css/markup/asciicast.css b/web_src/css/markup/asciicast.css index 89696bc7105..a45daaa8e8b 100644 --- a/web_src/css/markup/asciicast.css +++ b/web_src/css/markup/asciicast.css @@ -3,6 +3,8 @@ height: auto; } -.ap-terminal { +/* Related: https://github.com/asciinema/asciinema-player/blob/develop/src/components/Terminal.js :
+Old PR: Fix UI regression of asciinema player https://github.com/go-gitea/gitea/pull/26159 */ +.ap-term { overflow: hidden !important; } diff --git a/web_src/css/markup/content.css b/web_src/css/markup/content.css index e7a967a7c64..d90e3e01ec5 100644 --- a/web_src/css/markup/content.css +++ b/web_src/css/markup/content.css @@ -154,12 +154,6 @@ In markup content, we always use bottom margin for all elements */ padding-inline-start: 2em; } -.markup ul.no-list, -.markup ol.no-list { - padding: 0; - list-style-type: none; -} - .markup .task-list-item { list-style-type: none; } @@ -357,69 +351,6 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { color: var(--color-text); } -.markup span.align-center { - display: block; - overflow: hidden; - clear: both; -} - -.markup span.align-center > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: center; -} - -.markup span.align-center span img, -.markup span.align-center span video { - margin: 0 auto; - text-align: center; -} - -.markup span.align-right { - display: block; - overflow: hidden; - clear: both; -} - -.markup span.align-right > span { - display: block; - margin: 13px 0 0; - overflow: hidden; - text-align: right; -} - -.markup span.align-right span img, -.markup span.align-right span video { - margin: 0; - text-align: right; -} - -.markup span.float-left { - display: block; - float: left; - margin-inline-end: 13px; - overflow: hidden; -} - -.markup span.float-left span { - margin: 13px 0 0; -} - -.markup span.float-right { - display: block; - float: right; - margin-inline-start: 13px; - overflow: hidden; -} - -.markup span.float-right > span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: right; -} - .markup code, .markup tt { padding: 0.2em 0.4em; @@ -527,9 +458,11 @@ html[data-gitea-theme-dark="false"] .markup img[src*="#gh-dark-mode-only"] { } .external-render-iframe { + display: block; /* removes the inline baseline gap below the iframe */ width: 100%; height: max(300px, 80vh); border: none; + border-radius: 0 0 var(--border-radius) var(--border-radius); } .markup-content-iframe { diff --git a/web_src/css/modules/breadcrumb.css b/web_src/css/modules/breadcrumb.css index 77e31ef6275..39a896e9bb4 100644 --- a/web_src/css/modules/breadcrumb.css +++ b/web_src/css/modules/breadcrumb.css @@ -1,6 +1,7 @@ .breadcrumb { display: flex; align-items: center; + flex-wrap: wrap; gap: 3px; overflow-wrap: anywhere; } diff --git a/web_src/css/modules/card.css b/web_src/css/modules/card.css index c5ca6a1cc1f..ca3e4bf8906 100644 --- a/web_src/css/modules/card.css +++ b/web_src/css/modules/card.css @@ -122,14 +122,3 @@ a.ui.card:hover { color: var(--color-text); border-top-color: var(--color-secondary-light-1) !important; } - -.ui.three.cards { - margin-left: -1em; - margin-right: -1em; -} - -.ui.three.cards > .card { - width: calc(33.33333333333333% - 2em); - margin-left: 1em; - margin-right: 1em; -} diff --git a/web_src/css/modules/comment.css b/web_src/css/modules/comment.css index 2783328a6a5..d1f9049e174 100644 --- a/web_src/css/modules/comment.css +++ b/web_src/css/modules/comment.css @@ -32,23 +32,6 @@ padding-top: 0; } -.ui.comments .comment > .comments { - margin: 0 0 0.5em 0.5em; - padding: 1em 0 1em 1em; -} - -.ui.comments .comment > .comments::before { - position: absolute; - top: 0; - left: 0; -} - -.ui.comments .comment > .comments .comment { - border: none; - border-top: none; - background: none; -} - .ui.comments .comment > .content { display: flex; flex-direction: column; @@ -56,21 +39,6 @@ min-width: 0; } -.ui.comments .comment .metadata { - display: inline-block; - margin-left: 0.5em; - font-size: 0.875em; -} - -.ui.comments .comment .metadata > * { - display: inline-block; - margin: 0 0.5em 0 0; -} - -.ui.comments .comment .metadata > :last-child { - margin-right: 0; -} - .ui.comments .comment .text { margin: 0.25em 0 0.5em; font-size: 1em; diff --git a/web_src/css/modules/divider.css b/web_src/css/modules/divider.css index a60b7d52cbe..32d03885d35 100644 --- a/web_src/css/modules/divider.css +++ b/web_src/css/modules/divider.css @@ -36,3 +36,11 @@ h4.divider { .divider.divider-text::after { margin-left: .75em; } + +.inline-divider { + display: inline-block; + border-left: 1px solid var(--color-secondary); + overflow: hidden; + width: 1px; + margin: 0 var(--gap-inline); +} diff --git a/web_src/css/modules/form.css b/web_src/css/modules/form.css index 2d315786c6f..ffbf01c53c5 100644 --- a/web_src/css/modules/form.css +++ b/web_src/css/modules/form.css @@ -99,6 +99,13 @@ textarea:focus, color: var(--color-input-text); } +.ui.form input:not([type="checkbox"], [type="radio"])[readonly], +.ui.form textarea[readonly], +.ui.form select[readonly], +.ui.form .ui.selection.dropdown[readonly] { + background: var(--color-secondary-bg); +} + .ui.input { color: var(--color-input-text); } @@ -117,18 +124,12 @@ textarea:focus, .ui.form .fields:not(.grouped):not(.inline) .field:not(:only-child) .ui.toggle.checkbox { margin-top: 2.21428571em; } -.ui.form .fields:not(.grouped):not(.inline) .field:not(:only-child) .ui.slider.checkbox { - margin-top: 2.61428571em; -} .ui.ui.form .field .fields .field:not(:only-child) .ui.checkbox { margin-top: 0.6em; } .ui.ui.form .field .fields .field:not(:only-child) .ui.toggle.checkbox { margin-top: 0.5em; } -.ui.ui.form .field .fields .field:not(:only-child) .ui.slider.checkbox { - margin-top: 0.7em; -} .ui.form .field > .selection.dropdown { min-width: 14em; /* matches the default min width */ @@ -197,15 +198,14 @@ textarea:focus, .ui.form .field.error input { background-color: var(--color-error-bg); border-color: var(--color-error-border); - color: var(--color-error-text); - border-radius: 0; + color: var(--color-input-text); } .ui.form .field.error textarea:focus, .ui.form .field.error select:focus, .ui.form .field.error input:focus { background-color: var(--color-error-bg); border-color: var(--color-error-border); - color: var(--color-error-text); + color: var(--color-input-text); } .ui.form .field.error select { @@ -283,15 +283,11 @@ textarea:focus, font-weight: var(--font-weight-medium); text-transform: none; } -.ui.form .grouped.fields .field, -.ui.form .grouped.inline.fields .field { +.ui.form .grouped.fields .field { display: block; margin: 0.5em 0; padding: 0; } -.ui.form .grouped.inline.fields .ui.checkbox { - margin-bottom: 0.4em; -} .ui.form .fields { display: flex; diff --git a/web_src/css/modules/grid.css b/web_src/css/modules/grid.css index b4f4e16105b..3f3f10a7ec7 100644 --- a/web_src/css/modules/grid.css +++ b/web_src/css/modules/grid.css @@ -10,10 +10,6 @@ margin: -1rem; } -.ui.relaxed.grid { - margin-left: -1.5rem; - margin-right: -1.5rem; -} .ui[class*="very relaxed"].grid { margin-left: -2.5rem; margin-right: -2.5rem; @@ -71,18 +67,10 @@ margin-bottom: 0; } -.ui.grid .aligned.row > .column > .segment:not(.compact):not(.attached), -.ui.aligned.grid .column > .segment:not(.compact):not(.attached) { - width: 100%; -} - .ui.grid .row + .ui.divider { flex-grow: 1; margin: 1rem; } -.ui.grid .column + .ui.vertical.divider { - height: calc(50% - 1rem); -} .ui.grid > .row > .column:last-child > .horizontal.segment, .ui.grid > .column:last-child > .horizontal.segment { @@ -140,119 +128,14 @@ width: 100%; } -.ui[class*="one column"].grid > .row > .column, -.ui[class*="one column"].grid > .column:not(.row) { - width: 100%; -} .ui[class*="two column"].grid > .row > .column, .ui[class*="two column"].grid > .column:not(.row) { width: 50%; } -.ui[class*="three column"].grid > .row > .column, -.ui[class*="three column"].grid > .column:not(.row) { - width: 33.33333333%; -} -.ui[class*="four column"].grid > .row > .column, -.ui[class*="four column"].grid > .column:not(.row) { - width: 25%; -} -.ui[class*="five column"].grid > .row > .column, -.ui[class*="five column"].grid > .column:not(.row) { - width: 20%; -} -.ui[class*="six column"].grid > .row > .column, -.ui[class*="six column"].grid > .column:not(.row) { - width: 16.66666667%; -} -.ui[class*="seven column"].grid > .row > .column, -.ui[class*="seven column"].grid > .column:not(.row) { - width: 14.28571429%; -} -.ui[class*="eight column"].grid > .row > .column, -.ui[class*="eight column"].grid > .column:not(.row) { - width: 12.5%; -} -.ui[class*="nine column"].grid > .row > .column, -.ui[class*="nine column"].grid > .column:not(.row) { - width: 11.11111111%; -} -.ui[class*="ten column"].grid > .row > .column, -.ui[class*="ten column"].grid > .column:not(.row) { - width: 10%; -} -.ui[class*="eleven column"].grid > .row > .column, -.ui[class*="eleven column"].grid > .column:not(.row) { - width: 9.09090909%; -} -.ui[class*="twelve column"].grid > .row > .column, -.ui[class*="twelve column"].grid > .column:not(.row) { - width: 8.33333333%; -} -.ui[class*="thirteen column"].grid > .row > .column, -.ui[class*="thirteen column"].grid > .column:not(.row) { - width: 7.69230769%; -} -.ui[class*="fourteen column"].grid > .row > .column, -.ui[class*="fourteen column"].grid > .column:not(.row) { - width: 7.14285714%; -} -.ui[class*="fifteen column"].grid > .row > .column, -.ui[class*="fifteen column"].grid > .column:not(.row) { - width: 6.66666667%; -} -.ui[class*="sixteen column"].grid > .row > .column, -.ui[class*="sixteen column"].grid > .column:not(.row) { - width: 6.25%; -} -.ui.grid > [class*="one column"].row > .column { - width: 100% !important; -} .ui.grid > [class*="two column"].row > .column { width: 50% !important; } -.ui.grid > [class*="three column"].row > .column { - width: 33.33333333% !important; -} -.ui.grid > [class*="four column"].row > .column { - width: 25% !important; -} -.ui.grid > [class*="five column"].row > .column { - width: 20% !important; -} -.ui.grid > [class*="six column"].row > .column { - width: 16.66666667% !important; -} -.ui.grid > [class*="seven column"].row > .column { - width: 14.28571429% !important; -} -.ui.grid > [class*="eight column"].row > .column { - width: 12.5% !important; -} -.ui.grid > [class*="nine column"].row > .column { - width: 11.11111111% !important; -} -.ui.grid > [class*="ten column"].row > .column { - width: 10% !important; -} -.ui.grid > [class*="eleven column"].row > .column { - width: 9.09090909% !important; -} -.ui.grid > [class*="twelve column"].row > .column { - width: 8.33333333% !important; -} -.ui.grid > [class*="thirteen column"].row > .column { - width: 7.69230769% !important; -} -.ui.grid > [class*="fourteen column"].row > .column { - width: 7.14285714% !important; -} -.ui.grid > [class*="fifteen column"].row > .column { - width: 6.66666667% !important; -} -.ui.grid > [class*="sixteen column"].row > .column { - width: 6.25% !important; -} .ui.grid > .row > [class*="one wide"].column, .ui.grid > .column.row > [class*="one wide"].column, @@ -302,12 +185,6 @@ .ui.column.grid > [class*="eight wide"].column { width: 50% !important; } -.ui.grid > .row > [class*="nine wide"].column, -.ui.grid > .column.row > [class*="nine wide"].column, -.ui.grid > [class*="nine wide"].column, -.ui.column.grid > [class*="nine wide"].column { - width: 56.25% !important; -} .ui.grid > .row > [class*="ten wide"].column, .ui.grid > .column.row > [class*="ten wide"].column, .ui.grid > [class*="ten wide"].column, @@ -338,12 +215,6 @@ .ui.column.grid > [class*="fourteen wide"].column { width: 87.5% !important; } -.ui.grid > .row > [class*="fifteen wide"].column, -.ui.grid > .column.row > [class*="fifteen wide"].column, -.ui.grid > [class*="fifteen wide"].column, -.ui.column.grid > [class*="fifteen wide"].column { - width: 93.75% !important; -} .ui.grid > .row > [class*="sixteen wide"].column, .ui.grid > .column.row > [class*="sixteen wide"].column, .ui.grid > [class*="sixteen wide"].column, @@ -352,14 +223,12 @@ } .ui.centered.grid, -.ui.centered.grid > .row, -.ui.grid > .centered.row { +.ui.centered.grid > .row { text-align: center; justify-content: center; } .ui.centered.grid > .column:not(.aligned):not(.justified):not(.row), -.ui.centered.grid > .row > .column:not(.aligned):not(.justified), -.ui.grid .centered.row > .column:not(.aligned):not(.justified) { +.ui.centered.grid > .row > .column:not(.aligned):not(.justified) { text-align: left; } .ui.grid > .centered.column, @@ -369,12 +238,6 @@ margin-right: auto; } -.ui.relaxed.grid > .column:not(.row), -.ui.relaxed.grid > .row > .column, -.ui.grid > .relaxed.row > .column { - padding-left: 1.5rem; - padding-right: 1.5rem; -} .ui[class*="very relaxed"].grid > .column:not(.row), .ui[class*="very relaxed"].grid > .row > .column, .ui.grid > [class*="very relaxed"].row > .column { @@ -382,11 +245,6 @@ padding-right: 2.5rem; } -.ui.relaxed.grid .row + .ui.divider, -.ui.grid .relaxed.row + .ui.divider { - margin-left: 1.5rem; - margin-right: 1.5rem; -} .ui[class*="very relaxed"].grid .row + .ui.divider, .ui.grid [class*="very relaxed"].row + .ui.divider { margin-left: 2.5rem; @@ -439,23 +297,3 @@ margin-right: -1rem !important; } } - -.ui.ui.ui.compact.grid > .column:not(.row), -.ui.ui.ui.compact.grid > .row > .column { - padding-left: 0.5rem; - padding-right: 0.5rem; -} -.ui.ui.ui.compact.grid > * { - padding-left: 0.5rem; - padding-right: 0.5rem; -} - -.ui.ui.ui.compact.grid > .row { - padding-top: 0.5rem; - padding-bottom: 0.5rem; -} - -.ui.ui.ui.compact.grid > .column:not(.row) { - padding-top: 0.5rem; - padding-bottom: 0.5rem; -} diff --git a/web_src/css/modules/header.css b/web_src/css/modules/header.css index 20f98bfbac2..5a94836b983 100644 --- a/web_src/css/modules/header.css +++ b/web_src/css/modules/header.css @@ -24,10 +24,6 @@ vertical-align: middle; } -.ui.header > .ui.label.compact { - margin-top: inherit; -} - .ui.header .sub.header { display: block; font-weight: var(--font-weight-normal); diff --git a/web_src/css/modules/input.css b/web_src/css/modules/input.css index abf2d214925..af39824928a 100644 --- a/web_src/css/modules/input.css +++ b/web_src/css/modules/input.css @@ -38,7 +38,7 @@ .ui.input.error > input { background: var(--color-error-bg); border-color: var(--color-error-border); - color: var(--color-error-text); + color: var(--color-input-text); } .ui.icon.input > i.icon { @@ -69,42 +69,24 @@ visibility: hidden; } -.ui.ui.ui.ui.icon.input > textarea, .ui.ui.ui.ui.icon.input > input { padding-right: 2.67142857em; } -.ui.icon.input > i.link.icon { - cursor: pointer; -} -.ui.icon.input > i.circular.icon { - top: 0.35em; - right: 0.5em; -} .ui[class*="left icon"].input > i.icon { right: auto; left: 1px; border-radius: 0.28571429rem 0 0 0.28571429rem; } -.ui[class*="left icon"].input > i.circular.icon { - right: auto; - left: 0.5em; -} -.ui.ui.ui.ui[class*="left icon"].input > textarea, .ui.ui.ui.ui[class*="left icon"].input > input { padding-left: 2.67142857em; padding-right: 1em; } -.ui.icon.input > textarea:focus ~ .icon, .ui.icon.input > input:focus ~ .icon { opacity: 1; } -.ui.icon.input > textarea ~ i.icon { - height: 3em; -} - .ui.form .field.error > .ui.action.input > .ui.button, .ui.action.input.error > .ui.button { border-top: 1px solid var(--color-error-border); diff --git a/web_src/css/modules/label.css b/web_src/css/modules/label.css index 6db13e2954c..29b669fd9b0 100644 --- a/web_src/css/modules/label.css +++ b/web_src/css/modules/label.css @@ -184,12 +184,6 @@ a.ui.ui.ui.basic.yellow.label:hover { border-color: var(--color-yellow-dark-1); color: var(--color-yellow-dark-1); } -.ui.ui.ui.olive.label { - background: var(--color-olive); - border-color: var(--color-olive); - color: var(--color-white); -} - .ui.ui.ui.green.label { background: var(--color-green); border-color: var(--color-green); @@ -221,16 +215,6 @@ a.ui.ui.ui.purple.label:hover { border-color: var(--color-purple-dark-1); color: var(--color-white); } -.ui.ui.ui.basic.purple.label { - background: transparent; - border-color: var(--color-purple); - color: var(--color-purple); -} -a.ui.ui.ui.basic.purple.label:hover { - background: transparent; - border-color: var(--color-purple-dark-1); - color: var(--color-purple-dark-1); -} .ui.ui.ui.grey.label { background: var(--color-label-bg); @@ -242,16 +226,6 @@ a.ui.ui.ui.grey.label:hover { border-color: var(--color-label-hover-bg); color: var(--color-white); } -.ui.ui.ui.basic.grey.label { - background: transparent; - border-color: var(--color-label-bg); - color: var(--color-label-text); -} -a.ui.ui.ui.basic.grey.label:hover { - background: transparent; - border-color: var(--color-label-hover-bg); - color: var(--color-label-hover-bg); -} /* "horizontal label" is actually "fat label" which has enough padding spaces to be used standalone in headers */ .ui.horizontal.label { diff --git a/web_src/css/modules/menu.css b/web_src/css/modules/menu.css index 5072dcbd0e1..d371fe00e23 100644 --- a/web_src/css/modules/menu.css +++ b/web_src/css/modules/menu.css @@ -173,10 +173,6 @@ margin-top: 0.35714286em; } -.ui.menu .pointing.dropdown.item .menu { - margin-top: 0.75em; -} - .ui.menu .item > .label:not(.floating) { margin-left: 1em; padding: 0.3em 0.78571429em; @@ -188,9 +184,6 @@ float: right; text-align: center; } -.ui.menu .item > .floating.label { - padding: 0.3em 0.78571429em; -} .ui.menu .item > .label { background: var(--color-label-bg); color: var(--color-label-text); @@ -266,22 +259,12 @@ display: inherit; } -.ui.menu:not(.vertical) .center.item { - display: flex; - margin-left: auto !important; - margin-right: auto !important; -} - .ui.menu .right.item::before, .ui.menu .right.menu > .item::before { right: auto; left: 0; } -.ui.menu .center.item:last-child::before { - display: none; -} - .ui.vertical.menu { display: block; flex-direction: column; @@ -381,9 +364,6 @@ background: none transparent; border-bottom: 1px solid var(--color-secondary); } -.ui.tabular.fluid.menu { - width: calc(100% + 2px) !important; -} .ui.tabular.menu .item { background: transparent; border-bottom: none; @@ -523,14 +503,6 @@ background: var(--color-active); } -.ui.secondary.item.menu { - margin-left: 0; - margin-right: 0; -} -.ui.secondary.item.menu .item:last-child { - margin-right: 0; -} - .ui.vertical.secondary.menu .item:not(.dropdown) > .menu { margin: 0 -0.92857143em; } @@ -624,10 +596,6 @@ .ui.stackable.menu .right.item { margin-left: 0 !important; } - .ui.stackable.menu .center.item { - margin-left: 0 !important; - margin-right: 0 !important; - } .ui.stackable.menu .right.menu, .ui.stackable.menu .left.menu { flex-direction: column; @@ -635,8 +603,7 @@ } .ui.borderless.menu .item::before, -.ui.borderless.menu .item .menu .item::before, -.ui.menu .borderless.item::before { +.ui.borderless.menu .item .menu .item::before { background: none !important; } @@ -646,19 +613,12 @@ vertical-align: middle; flex-shrink: 0; } -.ui.compact.vertical.menu { - display: inline-block; - width: auto !important; -} .ui.compact.menu:not(.secondary) .item:last-child { border-radius: 0 0.28571429rem 0.28571429rem 0; } .ui.compact.menu .item:last-child::before { display: none; } -.ui.compact.vertical.menu .item:last-child::before { - display: block; -} .ui.menu.fluid, .ui.vertical.menu.fluid { @@ -675,9 +635,6 @@ text-align: center; justify-content: center; } -.ui.attached.item.menu:not(.tabular) { - margin: 0 -1px !important; -} .ui.item.menu .item:last-child::before { display: none; } diff --git a/web_src/css/modules/message.css b/web_src/css/modules/message.css index d5346616bce..bb0965a077f 100644 --- a/web_src/css/modules/message.css +++ b/web_src/css/modules/message.css @@ -12,6 +12,25 @@ border-radius: var(--border-radius); } +details.ui.message { + padding: 0; +} + +details.ui.message summary { + padding: 1em 1.5em; +} + +details.ui.message pre { + margin: -1.25em 0 0; + padding: 0.5em 1.5em; + white-space: pre-wrap; +} + +details.ui.message:not(:has(pre)) summary { + list-style: none; + cursor: text; +} + .ui.message:first-child { margin-top: 0; } @@ -51,7 +70,6 @@ .ui.blue.message, .ui.attached.blue.message { background: var(--color-info-bg); - color: var(--color-info-text); border-color: var(--color-info-border); } @@ -60,39 +78,24 @@ .ui.positive.message, .ui.attached.positive.message { background: var(--color-success-bg); - color: var(--color-success-text); border-color: var(--color-success-border); } .ui.error.message, .ui.attached.error.message, .ui.red.message, -.ui.attached.red.message, .ui.negative.message, .ui.attached.negative.message { background: var(--color-error-bg); - color: var(--color-error-text); border-color: var(--color-error-border); } .ui.warning.message, -.ui.attached.warning.message, -.ui.yellow.message, -.ui.attached.yellow.message { +.ui.attached.warning.message { background: var(--color-warning-bg); - color: var(--color-warning-text); border-color: var(--color-warning-border); } -/* use opaque colors for buttons inside colored messages */ -.ui.message .ui.button:hover { - background: var(--color-secondary); -} - -.ui.message .ui.button:active { - background: var(--color-secondary-hover); -} - .ui.message > .close.icon { cursor: pointer; position: absolute; diff --git a/web_src/css/modules/modal.css b/web_src/css/modules/modal.css index 5d686746cbe..d45e54b947b 100644 --- a/web_src/css/modules/modal.css +++ b/web_src/css/modules/modal.css @@ -159,19 +159,11 @@ display: block; } -.scrolling.dimmable.dimmed { - overflow: hidden; -} - .scrolling.dimmable > .dimmer { justify-content: flex-start; position: fixed; } -.scrolling.dimmable.dimmed > .dimmer { - overflow: auto; -} - .modals.dimmer .ui.scrolling.modal { margin: 2rem auto; } diff --git a/web_src/css/modules/svg.css b/web_src/css/modules/svg.css index e32fa0911f1..7adfc77a818 100644 --- a/web_src/css/modules/svg.css +++ b/web_src/css/modules/svg.css @@ -6,10 +6,6 @@ fill: currentcolor; } -.middle .svg { - vertical-align: middle; -} - /* some browsers like Chrome have a bug: when a SVG is in a "display: none" container and referenced somewhere else by ``, it won't be rendered correctly. e.g.: ".kts -> kotlin" */ .svg-icon-container { @@ -50,3 +46,9 @@ .svg[width="36"] { min-width: 36px; } .svg[width="48"] { min-width: 48px; } .svg[width="56"] { min-width: 56px; } + +/* when the svg is used in menu or item, it's certain that we don't want it to be shrunk */ +.menu .svg, +.item .svg { + flex-shrink: 0; +} diff --git a/web_src/css/modules/table.css b/web_src/css/modules/table.css index c7d6cb7a485..76c68b69883 100644 --- a/web_src/css/modules/table.css +++ b/web_src/css/modules/table.css @@ -226,18 +226,10 @@ .ui.table td.six.wide { width: 37.5%; } -.ui.table th.seven.wide, -.ui.table td.seven.wide { - width: 43.75%; -} .ui.table th.eight.wide, .ui.table td.eight.wide { width: 50%; } -.ui.table th.nine.wide, -.ui.table td.nine.wide { - width: 56.25%; -} .ui.table th.ten.wide, .ui.table td.ten.wide { width: 62.5%; @@ -246,26 +238,6 @@ .ui.table td.eleven.wide { width: 68.75%; } -.ui.table th.twelve.wide, -.ui.table td.twelve.wide { - width: 75%; -} -.ui.table th.thirteen.wide, -.ui.table td.thirteen.wide { - width: 81.25%; -} -.ui.table th.fourteen.wide, -.ui.table td.fourteen.wide { - width: 87.5%; -} -.ui.table th.fifteen.wide, -.ui.table td.fifteen.wide { - width: 93.75%; -} -.ui.table th.sixteen.wide, -.ui.table td.sixteen.wide { - width: 100%; -} .ui.basic.table { background: transparent; diff --git a/web_src/css/org.css b/web_src/css/org.css index 48b41de297e..b54a21ac6e1 100644 --- a/web_src/css/org.css +++ b/web_src/css/org.css @@ -18,39 +18,17 @@ margin-bottom: 10px; } -.page-content.organization #org-info .meta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; +.page-content.organization .team-item-box > .team-item-header { + min-height: 50px; /* the header sometimes contains a mini button, sometimes not, so we set a min-height to make sure the layout is consistent */ } -.page-content.organization .ui.top.header .ui.right { - margin-top: 0; -} - -.page-content.organization .teams .item { - padding: 10px 15px; -} - -.page-content.organization .members .ui.avatar { - margin-right: 5px; - margin-bottom: 5px; -} - -.organization.invite #invite-box { - margin: 50px auto auto; - width: 500px !important; -} - -.organization.invite #invite-box #search-user-box input { - margin-left: 0; - width: 300px; -} - -.organization.invite #invite-box .ui.button { - margin-left: 5px; - margin-top: -3px; +.page-content.organization .team-item-box .team-item-description { + padding-top: 0.5em; + padding-bottom: 0.5em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-light-3); } .organization.invite .ui.avatar { diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 95d6ca21695..bd436e89b06 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -62,12 +62,23 @@ visibility: hidden; } +.sidebar-project-card { + border: 1px solid var(--color-secondary); + border-radius: var(--border-radius); + margin-top: var(--gap-block); + padding: 8px; +} + .issue-content-right .ui.list.labels-list { display: flex; gap: var(--gap-inline); flex-wrap: wrap; } +.issue-content-right .empty-list { + font-size: 12px; +} + @media (max-width: 767.98px) { .issue-content-left, .issue-content-right { @@ -187,23 +198,20 @@ td .commit-summary { padding: 0 !important; } -.repo-editor-menu { - min-height: auto !important; -} - .repo-editor-header { + /* it should match ".repo-button-row" so the tree toggle button stays aligned */ + margin: 8px 0; display: flex; - margin: 1rem 0; - padding: 3px 0; - width: 100%; - gap: 0.5em; align-items: center; + gap: 8px; + width: 100%; } .repo-editor-header input { vertical-align: middle !important; width: auto !important; - padding: 7px 8px !important; + height: 30px !important; + padding: 5px 8px !important; margin-right: 5px !important; } @@ -287,10 +295,6 @@ td .commit-summary { min-width: 100px; } -.repository.view.issue .instruct-toggle { - display: inline-block; -} - /* issue title & meta & edit */ .issue-title-header { width: 100%; @@ -1463,11 +1467,6 @@ tbody.commit-list { } } -.commit-list .commit-status-link { - display: inline-block; - vertical-align: middle; -} - .commit-body { margin: 0.25em 0; white-space: pre-wrap; diff --git a/web_src/css/repo/home-file-list.css b/web_src/css/repo/home-file-list.css index 6aa9e4bca3d..485c544196f 100644 --- a/web_src/css/repo/home-file-list.css +++ b/web_src/css/repo/home-file-list.css @@ -92,3 +92,8 @@ white-space: nowrap; color: var(--color-text-light-1); } + +#repo-files-table .repo-file-cell.is-loading::after { + height: 40%; + border-width: 2px; +} diff --git a/web_src/css/swagger.css b/web_src/css/swagger-render.css similarity index 52% rename from web_src/css/swagger.css rename to web_src/css/swagger-render.css index c20eda7948d..1def667e505 100644 --- a/web_src/css/swagger.css +++ b/web_src/css/swagger-render.css @@ -1,9 +1,5 @@ @import "../../node_modules/swagger-ui-dist/swagger-ui.css"; -body { - margin: 0; -} - html, html body, html .swagger-ui, @@ -15,27 +11,3 @@ html .swagger-ui .scheme-container { html.dark-mode .swagger-ui table.headers td { color: var(--color-text) !important; } - -.swagger-back-link { - color: var(--color-primary); - text-decoration: none; - position: absolute; - top: 1rem; - right: 1.5rem; - display: flex; - align-items: center; -} - -.swagger-back-link:hover { - text-decoration: underline; -} - -.swagger-back-link svg { - color: inherit; - fill: currentcolor; - margin-right: 0.5rem; -} - -.swagger-spec-content { - display: none; -} diff --git a/web_src/css/swagger-standalone.css b/web_src/css/swagger-standalone.css new file mode 100644 index 00000000000..ae36ab49cf5 --- /dev/null +++ b/web_src/css/swagger-standalone.css @@ -0,0 +1,29 @@ +@import "swagger-render.css"; + +body { + margin: 0; +} + +.swagger-back-link { + color: var(--color-primary); + text-decoration: none; + position: absolute; + top: 1rem; + right: 1.5rem; + display: flex; + align-items: center; +} + +.swagger-back-link:hover { + text-decoration: underline; +} + +.swagger-back-link svg { + color: inherit; + fill: currentcolor; + margin-right: 0.5rem; +} + +.swagger-spec-content { + display: none; +} diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css index 9b2331ef410..188a30cae06 100644 --- a/web_src/css/themes/theme-gitea-dark.css +++ b/web_src/css/themes/theme-gitea-dark.css @@ -163,16 +163,19 @@ gitea-theme-meta-info { --color-error-bg: #322226; --color-error-bg-active: #49262a; --color-error-bg-hover: #3c2427; - --color-error-text: var(--color-text); + --color-error-text: #f85149; --color-success-border: #225633; --color-success-bg: #1c3329; - --color-success-text: var(--color-text); + --color-success-text: #3fb950; --color-warning-border: #5f481a; --color-warning-bg: #342e1f; - --color-warning-text: var(--color-text); + --color-warning-text: #d29922; --color-info-border: #254a7e; --color-info-bg: #1b283a; - --color-info-text: var(--color-text); + --color-info-text: #2f81f7; + --color-priority-border: #4a268d; + --color-priority-bg: #251c39; + --color-priority-text: #a371f7; --color-red-badge: #db2828; --color-red-badge-bg: #db28281a; --color-red-badge-hover-bg: #db28284d; diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css index 7e3ed763c78..fb9b8979da3 100644 --- a/web_src/css/themes/theme-gitea-light.css +++ b/web_src/css/themes/theme-gitea-light.css @@ -163,16 +163,19 @@ gitea-theme-meta-info { --color-error-bg: #ffebe9; --color-error-bg-active: #ffcecb; --color-error-bg-hover: #ffdcd7; - --color-error-text: var(--color-text); + --color-error-text: #d1242f; --color-success-border: #4ac26b66; --color-success-bg: #dafbe1; - --color-success-text: var(--color-text); + --color-success-text: #1a7f37; --color-warning-border: #d4a72c66; --color-warning-bg: #fff8c5; - --color-warning-text: var(--color-text); + --color-warning-text: #9a6700; --color-info-border: #54aeff66; --color-info-bg: #ddf4ff; - --color-info-text: var(--color-text); + --color-info-text: #0969da; + --color-priority-border: #b9a1ff66; + --color-priority-bg: #f3e7ff; + --color-priority-text: #8250df; --color-red-badge: #db2828; --color-red-badge-bg: #db28281a; --color-red-badge-hover-bg: #db28284d; diff --git a/web_src/js/components/ActionRunArtifacts.test.ts b/web_src/js/components/ActionRunArtifacts.test.ts new file mode 100644 index 00000000000..f539bcb2801 --- /dev/null +++ b/web_src/js/components/ActionRunArtifacts.test.ts @@ -0,0 +1,36 @@ +import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts'; +import {normalizeTestHtml} from '../utils/testhelper.ts'; + +describe('buildArtifactTooltipHtml', () => { + test('active artifact', () => { + const expiresUnix = Date.UTC(2026, 2, 20, 12, 0, 0) / 1000; + const expiresLocal = new Date(expiresUnix * 1000).toLocaleString(); + const result = buildArtifactTooltipHtml({ + name: 'artifact.zip', + size: 1024 * 1024, + status: 'completed', + expiresUnix, + }, 'Expires at %s (extra)'); + + expect(normalizeTestHtml(result)).toBe(normalizeTestHtml(` +Expires at + + ${expiresLocal} + + (extra) + , + 1.0 MiB + +`)); + }); + + test('no expiry', () => { + const result = buildArtifactTooltipHtml({ + name: 'artifact.zip', + size: 512, + status: 'completed', + expiresUnix: 0, + }, 'Expires at %s'); + expect(normalizeTestHtml(result)).toBe(`512 B`); + }); +}); diff --git a/web_src/js/components/ActionRunArtifacts.ts b/web_src/js/components/ActionRunArtifacts.ts new file mode 100644 index 00000000000..84787fc5709 --- /dev/null +++ b/web_src/js/components/ActionRunArtifacts.ts @@ -0,0 +1,24 @@ +import {html} from '../utils/html.ts'; +import {formatBytes} from '../utils.ts'; +import type {ActionsArtifact} from '../modules/gitea-actions.ts'; + +export function buildArtifactTooltipHtml(artifact: ActionsArtifact, expiresAtLocale: string): string { + const sizeText = formatBytes(artifact.size); + if (artifact.expiresUnix <= 0) { + return html`${sizeText}`; // use the same layout as below + } + const datetimeLocal = new Date(artifact.expiresUnix * 1000).toLocaleString(); + // split so the element can be interleaved, e.g. "Expires at %s" -> ["Expires at ", ""] + const [prefix, suffix = ''] = expiresAtLocale.split('%s'); + return html` + + ${prefix} + + ${datetimeLocal} + + ${suffix} + , + ${sizeText} + + `; +} diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue index 67d4c1048b5..bce1d079c7e 100644 --- a/web_src/js/components/ActionRunJobView.vue +++ b/web_src/js/components/ActionRunJobView.vue @@ -77,9 +77,8 @@ defineOptions({ const props = defineProps<{ store: ActionRunViewStore, - runId: number; jobId: number; - actionsUrl: string; + actionsViewUrl: string; locale: Record; }>(); const store = props.store; @@ -270,8 +269,7 @@ async function fetchJobData(abortController: AbortController): Promise // for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc return {step: idx, cursor: it.cursor, expanded: it.expanded}; }); - const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`; - const resp = await POST(url, { + const resp = await POST(props.actionsViewUrl, { signal: abortController.signal, data: {logCursors}, }); @@ -663,6 +661,14 @@ async function hashChangeListener() { background: var(--color-warning-bg); } +.job-step-logs .log-line-notice { + background: var(--color-info-bg); +} + +.job-step-logs .log-line-debug { + background: var(--color-secondary-alpha-30); +} + .job-step-logs .log-cmd-error > .log-msg-label { color: var(--color-error-text); } @@ -671,7 +677,11 @@ async function hashChangeListener() { color: var(--color-warning-text); } -.job-step-logs .log-cmd-debug { +.job-step-logs .log-cmd-notice > .log-msg-label { + color: var(--color-info-text); +} + +.job-step-logs .log-cmd-debug > .log-msg-label { color: var(--color-violet); } diff --git a/web_src/js/components/ActionRunSummaryView.vue b/web_src/js/components/ActionRunSummaryView.vue index 48af966c94b..a50ccaf5b6f 100644 --- a/web_src/js/components/ActionRunSummaryView.vue +++ b/web_src/js/components/ActionRunSummaryView.vue @@ -13,11 +13,18 @@ const props = defineProps<{ locale: Record; }>(); +const locale = props.locale; const {currentRun: run} = toRefs(props.store.viewData); -const runTriggeredAtIso = computed(() => { - const t = props.store.viewData.currentRun.triggeredAt; - return t ? new Date(t * 1000).toISOString() : ''; +const isRerun = computed(() => run.value.runAttempt > 1); + +const triggerUser = computed(() => { + const currentAttempt = run.value.attempts.find((attempt) => attempt.current); + if (currentAttempt) { + return {name: currentAttempt.triggerUserName, link: currentAttempt.triggerUserLink}; + } + const pusher = run.value.commit.pusher; + return pusher.displayName ? {name: pusher.displayName, link: pusher.link} : null; }); onMounted(async () => { @@ -32,7 +39,14 @@ onBeforeUnmount(() => {
- {{ locale.triggeredVia.replace('%s', run.triggerEvent) }} • + {{ isRerun ? locale.rerun : locale.triggeredVia.replace('%s', run.triggerEvent) }} + + • +
diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts index 133b7263eba..1bc1844dc1d 100644 --- a/web_src/js/components/ActionRunView.ts +++ b/web_src/js/components/ActionRunView.ts @@ -91,6 +91,7 @@ export function createEmptyActionsRun(): ActionsRun { return { repoId: 0, link: '', + viewLink: '', title: '', titleHTML: '', status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon @@ -103,6 +104,8 @@ export function createEmptyActionsRun(): ActionsRun { workflowID: '', workflowLink: '', isSchedule: false, + runAttempt: 0, + attempts: [], duration: '', triggeredAt: 0, triggerEvent: '', @@ -125,7 +128,7 @@ export function createEmptyActionsRun(): ActionsRun { }; } -export function createActionRunViewStore(actionsUrl: string, runId: number) { +export function createActionRunViewStore(viewUrl: string) { let loadingAbortController: AbortController | null = null; let intervalID: IntervalId | null = null; const viewData = reactive({ @@ -137,8 +140,7 @@ export function createActionRunViewStore(actionsUrl: string, runId: number) { const abortController = new AbortController(); loadingAbortController = abortController; try { - const url = `${actionsUrl}/runs/${runId}`; - const resp = await POST(url, {signal: abortController.signal, data: {}}); + const resp = await POST(viewUrl, {signal: abortController.signal, data: {}}); const runResp = await resp.json(); if (loadingAbortController !== abortController) return; diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue index 7c7e0cd94ca..08c590aa16b 100644 --- a/web_src/js/components/ActivityHeatmap.vue +++ b/web_src/js/components/ActivityHeatmap.vue @@ -1,21 +1,23 @@ diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue index 12911aad404..38e83d2c1f7 100644 --- a/web_src/js/components/ContextPopup.vue +++ b/web_src/js/components/ContextPopup.vue @@ -1,63 +1,40 @@