diff --git a/.github/actions/go-setup/action.yml b/.github/actions/go-setup/action.yml new file mode 100644 index 00000000000..d6f88bbc6da --- /dev/null +++ b/.github/actions/go-setup/action.yml @@ -0,0 +1,23 @@ +name: go-setup +description: Set up go and restore caches + +inputs: + cache: + description: Restore go caches + default: "true" + lint-cache: + description: Also restore the golangci-lint cache + default: "false" + +runs: + using: composite + steps: + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + check-latest: true + cache: false + - if: ${{ inputs.cache == 'true' }} + uses: ./.github/actions/go-cache + with: + lint-cache: ${{ inputs.lint-cache }} diff --git a/.github/actions/node-setup/action.yml b/.github/actions/node-setup/action.yml new file mode 100644 index 00000000000..c9ab484d57e --- /dev/null +++ b/.github/actions/node-setup/action.yml @@ -0,0 +1,22 @@ +name: node-setup +description: Set up pnpm and node and restore caches + +inputs: + cache: + description: Cache pnpm downloads + default: "true" + +runs: + using: composite + steps: + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - if: ${{ inputs.cache == 'true' }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - if: ${{ inputs.cache != 'true' }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 diff --git a/.github/actions/pgsql-shard/action.yml b/.github/actions/pgsql-shard/action.yml new file mode 100644 index 00000000000..9a5a03ae0de --- /dev/null +++ b/.github/actions/pgsql-shard/action.yml @@ -0,0 +1,40 @@ +name: pgsql-shard +description: Run one pgsql integration test shard + +inputs: + shard: + description: Shard index + required: true + total-shards: + description: Total shard count + required: true + run-migration: + description: Also run migration tests + default: "false" + +runs: + using: composite + steps: + - name: Add hosts to /etc/hosts + shell: bash + run: '[ -e "/.dockerenv" ] || [ -e "/run/.containerenv" ] || echo "127.0.0.1 pgsql ldap minio" | sudo tee -a /etc/hosts' + - shell: bash + run: make deps-backend + - shell: bash + run: make backend + env: + TAGS: bindata + - name: run migration tests + if: ${{ inputs.run-migration == 'true' }} + shell: bash + run: GITEA_TEST_DATABASE=pgsql make test-migration + - name: run tests + shell: bash + run: GITEA_TEST_DATABASE=pgsql make test-integration + env: + # pgsql is chosen to be the unlucky one to run with the slow "race detector", it is about 60% slower. + GOTEST_FLAGS: -race -timeout=40m + TAGS: bindata gogit + TEST_LDAP: 1 + TEST_SHARD: ${{ inputs.shard }} + TEST_TOTAL_SHARDS: ${{ inputs.total-shards }} diff --git a/.github/workflows/cache-seeder.yml b/.github/workflows/cache-seeder.yml index 91109b1c06d..8ec7adee07d 100644 --- a/.github/workflows/cache-seeder.yml +++ b/.github/workflows/cache-seeder.yml @@ -15,6 +15,7 @@ on: - "go.sum" - ".golangci.yml" - ".github/actions/go-cache/action.yml" + - ".github/actions/go-setup/action.yml" - ".github/workflows/cache-seeder.yml" concurrency: @@ -29,12 +30,7 @@ jobs: runs-on: ubuntu-latest 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 + - uses: ./.github/actions/go-setup - run: make deps-backend deps-tools - run: TAGS="bindata" make backend - run: TAGS="bindata gogit" GOEXPERIMENT="" make backend @@ -64,12 +60,7 @@ jobs: - { tags: "bindata", target: "lint-backend" } 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 + - uses: ./.github/actions/go-setup with: lint-cache: "true" - run: make deps-backend deps-tools diff --git a/.github/workflows/files-changed.yml b/.github/workflows/files-changed.yml index ac2bd1a5ba5..a7b116fec79 100644 --- a/.github/workflows/files-changed.yml +++ b/.github/workflows/files-changed.yml @@ -25,6 +25,8 @@ on: value: ${{ jobs.detect.outputs.json }} e2e: value: ${{ jobs.detect.outputs.e2e }} + shell: + value: ${{ jobs.detect.outputs.shell }} permissions: contents: read @@ -45,6 +47,7 @@ jobs: yaml: ${{ steps.changes.outputs.yaml }} json: ${{ steps.changes.outputs.json }} e2e: ${{ steps.changes.outputs.e2e }} + shell: ${{ steps.changes.outputs.shell }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 @@ -81,6 +84,7 @@ jobs: actions: - ".github/workflows/*" + - ".github/actions/**" - "Makefile" templates: @@ -123,3 +127,7 @@ jobs: - "tests/e2e/**" - "tools/test-e2e.sh" - "playwright.config.ts" + + shell: + - "**/*.sh" + - ".shellcheckrc" diff --git a/.github/workflows/pull-compliance.yml b/.github/workflows/pull-compliance.yml index ada537a188c..801966e1444 100644 --- a/.github/workflows/pull-compliance.yml +++ b/.github/workflows/pull-compliance.yml @@ -20,12 +20,7 @@ jobs: runs-on: ubuntu-latest 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 + - uses: ./.github/actions/go-setup with: lint-cache: "true" - run: make deps-backend deps-tools @@ -37,15 +32,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + - uses: ./.github/actions/go-setup with: - go-version-file: go.mod - check-latest: true - cache: false - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + cache: "false" + - uses: ./.github/actions/node-setup with: - node-version: 24 + cache: "false" - run: make lint-spell @@ -62,18 +54,16 @@ jobs: - if: needs.files-changed.outputs.actions == 'true' run: make lint-actions + - if: needs.files-changed.outputs.shell == 'true' + run: make lint-shell + checks-backend: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest 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 + - uses: ./.github/actions/go-setup - run: make deps-backend deps-tools - run: make --always-make checks-backend # ensure the "go-licenses" make target runs @@ -83,12 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + - uses: ./.github/actions/node-setup - run: make deps-frontend - run: make lint-frontend - run: make checks-frontend @@ -101,12 +86,7 @@ jobs: runs-on: ubuntu-latest 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 + - uses: ./.github/actions/go-setup - run: make deps-backend generate-go # no frontend build here as backend should be able to build, even without any frontend files # CGO is not used when cross-compile, so these steps also test if the code is compatible with CGO disabled diff --git a/.github/workflows/pull-db-tests.yml b/.github/workflows/pull-db-tests.yml index bc73d6391ca..1a3ce400352 100644 --- a/.github/workflows/pull-db-tests.yml +++ b/.github/workflows/pull-db-tests.yml @@ -14,14 +14,11 @@ jobs: files-changed: uses: ./.github/workflows/files-changed.yml - test-pgsql-shards: + test-pgsql-shard-1: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' needs: files-changed runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2] + timeout-minutes: 50 services: pgsql: image: postgres:14 @@ -46,31 +43,47 @@ jobs: - "9000:9000" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + - uses: ./.github/actions/go-setup + - uses: ./.github/actions/pgsql-shard with: - go-version-file: go.mod - check-latest: true - cache: false - - uses: ./.github/actions/go-cache - - 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 - - run: make backend + shard: 1 + total-shards: 2 + run-migration: "true" + + test-pgsql-shard-2: + if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' + needs: files-changed + runs-on: ubuntu-latest + timeout-minutes: 50 + services: + pgsql: + image: postgres:14 env: - TAGS: bindata - - name: run migration tests - if: matrix.shard == 1 - run: GITEA_TEST_DATABASE=pgsql make test-migration - - name: run tests - run: GITEA_TEST_DATABASE=pgsql make test-integration - timeout-minutes: 50 + POSTGRES_DB: test + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + ldap: + image: gitea/test-openldap:latest@sha256:4ac633b01d684e6b2a458cc0c8530c92f9b3702f6e040ce5f365607df34fbda0 + ports: + - "389:389" + - "636:636" + minio: + # as github actions doesn't support "entrypoint", we need to use a non-official image + # that has a custom entrypoint set to "minio server /data" + image: bitnamilegacy/minio:2025.7.23 env: - # pgsql is chosen to be the unlucky one to run with the slow "race detector", it is about 60% slower. - GOTEST_FLAGS: -race -timeout=40m - TAGS: bindata gogit - TEST_LDAP: 1 - TEST_SHARD: ${{ matrix.shard }} - TEST_TOTAL_SHARDS: ${{ strategy.job-total }} + MINIO_ROOT_USER: 123456 + MINIO_ROOT_PASSWORD: 12345678 + ports: + - "9000:9000" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/go-setup + - uses: ./.github/actions/pgsql-shard + with: + shard: 2 + total-shards: 2 test-sqlite: if: needs.files-changed.outputs.backend == 'true' || needs.files-changed.outputs.actions == 'true' @@ -78,12 +91,7 @@ jobs: runs-on: ubuntu-latest 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 + - uses: ./.github/actions/go-setup - run: make deps-backend - run: make backend env: @@ -111,6 +119,7 @@ jobs: env: discovery.type: single-node xpack.security.enabled: false + ES_JAVA_OPTS: "-Xms512m -Xmx512m" # reduce from ES default of 50% ports: - "9200:9200" meilisearch: @@ -141,12 +150,7 @@ jobs: - 10000:10000 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 + - uses: ./.github/actions/go-setup - 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 @@ -189,6 +193,7 @@ jobs: env: discovery.type: single-node xpack.security.enabled: false + ES_JAVA_OPTS: "-Xms512m -Xmx512m" # reduce from ES default of 50% ports: - "9200:9200" smtpimap: @@ -200,12 +205,7 @@ jobs: - "993:993" 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 + - uses: ./.github/actions/go-setup - 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 @@ -238,12 +238,7 @@ jobs: - 10000:10000 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 + - uses: ./.github/actions/go-setup - 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-e2e-tests.yml b/.github/workflows/pull-e2e-tests.yml index 7bf73d4a0f9..f0283f40227 100644 --- a/.github/workflows/pull-e2e-tests.yml +++ b/.github/workflows/pull-e2e-tests.yml @@ -20,18 +20,8 @@ jobs: runs-on: ubuntu-latest 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 - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + - uses: ./.github/actions/go-setup + - uses: ./.github/actions/node-setup - run: make deps-frontend - run: make frontend - run: make deps-backend diff --git a/.github/workflows/release-nightly-snapcraft.yml b/.github/workflows/release-nightly-snapcraft.yml new file mode 100644 index 00000000000..0f9ac1d423b --- /dev/null +++ b/.github/workflows/release-nightly-snapcraft.yml @@ -0,0 +1,41 @@ +name: release-nightly-snapcraft + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install snapcraft + run: sudo snap install snapcraft --classic + + - name: Remote build + run: | + snapcraft remote-build \ + --launchpad-accept-public-upload \ + --build-for=amd64,arm64,armhf + + - name: List built snaps + run: find . -maxdepth 1 -type f -name '*.snap' -print + + - name: Upload and release snapcraft nightly build + run: | + set -euo pipefail + + for snap in ./*.snap; do + echo "Uploading $snap to edge" + snapcraft upload --release="latest/edge" "$snap" + done diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 00000000000..a012fba96e5 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1 @@ +disable=SC1091,SC2001,SC2002,SC2016,SC2028,SC2046,SC2124,SC2128,SC2129,SC2154,SC2155,SC2164,SC2181,SC2207 diff --git a/Makefile b/Makefile index 94f07a44125..52a266fa39b 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,9 @@ SWAGGER_PACKAGE ?= github.com/go-swagger/go-swagger/cmd/swagger@v0.33.2 # renova XGO_PACKAGE ?= src.techknowlogick.com/xgo@v1.9.0 # renovate: datasource=go GOVULNCHECK_PACKAGE ?= golang.org/x/vuln/cmd/govulncheck@v1.3.0 # renovate: datasource=go ACTIONLINT_PACKAGE ?= github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 # renovate: datasource=go +SHELLCHECK_IMAGE ?= docker.io/koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d # renovate: datasource=docker + +CONTAINER_RUNTIME ?= $(shell hash docker >/dev/null 2>&1 && echo docker || echo podman) HAS_GO := $(shell hash $(GO) > /dev/null 2>&1 && echo yes) ifeq ($(HAS_GO), yes) @@ -271,7 +274,7 @@ checks-frontend: lockfile-check svg-check ## check frontend files checks-backend: tidy-check swagger-check openapi3-check fmt-check swagger-validate security-check ## check backend files .PHONY: lint -lint: lint-frontend lint-backend lint-templates lint-swagger lint-spell lint-md lint-actions lint-json lint-yaml ## lint everything +lint: lint-frontend lint-backend lint-templates lint-swagger lint-spell lint-md lint-actions lint-json lint-yaml lint-shell ## lint everything .PHONY: lint-fix lint-fix: lint-frontend-fix lint-backend-fix lint-spell-fix ## lint everything and fix issues @@ -348,6 +351,10 @@ lint-actions: .venv ## lint action workflow files @$(GO) run $(ACTIONLINT_PACKAGE) @uv run --frozen zizmor --quiet --min-confidence=medium .github +.PHONY: lint-shell +lint-shell: ## lint shell scripts + @SHELLCHECK_IMAGE=$(SHELLCHECK_IMAGE) CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) ./tools/lint-shell.sh $$(git ls-files '*.sh') + .PHONY: lint-templates lint-templates: .venv node_modules ## lint template files @node tools/lint-templates-svg.ts @@ -473,11 +480,11 @@ migrations.individual.test\#%: .PHONY: playwright playwright: deps-frontend - @./tools/test-e2e.sh install + @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) ./tools/test-e2e.sh install .PHONY: test-e2e test-e2e: playwright frontend backend - @EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh run $(GITEA_TEST_E2E_FLAGS) + @CONTAINER_RUNTIME=$(CONTAINER_RUNTIME) EXECUTABLE=$(EXECUTABLE) ./tools/test-e2e.sh run $(GITEA_TEST_E2E_FLAGS) .PHONY: build build: frontend backend ## build everything diff --git a/contrib/upgrade.sh b/contrib/upgrade.sh index 2593d245093..71d0435d146 100755 --- a/contrib/upgrade.sh +++ b/contrib/upgrade.sh @@ -126,6 +126,7 @@ giteacmd manager flush-queues echo "Stopping gitea at $(date)" $service_stop echo "Creating backup in $giteahome" +# shellcheck disable=SC2086 # flag string giteacmd dump $backupopts echo "Updating binary at $giteabin" cp -f "$giteabin" "$giteabin.bak" && mv -f "$binname" "$giteabin" diff --git a/docker/rootless/usr/local/bin/docker-entrypoint.sh b/docker/rootless/usr/local/bin/docker-entrypoint.sh index ca509214bff..4ffc4ff3a11 100755 --- a/docker/rootless/usr/local/bin/docker-entrypoint.sh +++ b/docker/rootless/usr/local/bin/docker-entrypoint.sh @@ -13,5 +13,5 @@ fi if [ $# -gt 0 ]; then exec "$@" else - exec /usr/local/bin/gitea -c ${GITEA_APP_INI} web + exec /usr/local/bin/gitea -c "${GITEA_APP_INI}" web fi diff --git a/docker/rootless/usr/local/bin/docker-setup.sh b/docker/rootless/usr/local/bin/docker-setup.sh index feab02a3793..3320d69dde8 100755 --- a/docker/rootless/usr/local/bin/docker-setup.sh +++ b/docker/rootless/usr/local/bin/docker-setup.sh @@ -1,23 +1,23 @@ #!/bin/bash # Prepare git folder -mkdir -p ${HOME} && chmod 0700 ${HOME} -if [ ! -w ${HOME} ]; then echo "${HOME} is not writable"; exit 1; fi +mkdir -p "${HOME}" && chmod 0700 "${HOME}" +if [ ! -w "${HOME}" ]; then echo "${HOME} is not writable"; exit 1; fi # Prepare custom folder -mkdir -p ${GITEA_CUSTOM} && chmod 0700 ${GITEA_CUSTOM} +mkdir -p "${GITEA_CUSTOM}" && chmod 0700 "${GITEA_CUSTOM}" # Prepare temp folder -mkdir -p ${GITEA_TEMP} && chmod 0700 ${GITEA_TEMP} -if [ ! -w ${GITEA_TEMP} ]; then echo "${GITEA_TEMP} is not writable"; exit 1; fi +mkdir -p "${GITEA_TEMP}" && chmod 0700 "${GITEA_TEMP}" +if [ ! -w "${GITEA_TEMP}" ]; then echo "${GITEA_TEMP} is not writable"; exit 1; fi #Prepare config file -if [ ! -f ${GITEA_APP_INI} ]; then +if [ ! -f "${GITEA_APP_INI}" ]; then #Prepare config file folder - GITEA_APP_INI_DIR=$(dirname ${GITEA_APP_INI}) - mkdir -p ${GITEA_APP_INI_DIR} && chmod 0700 ${GITEA_APP_INI_DIR} - if [ ! -w ${GITEA_APP_INI_DIR} ]; then echo "${GITEA_APP_INI_DIR} is not writable"; exit 1; fi + GITEA_APP_INI_DIR=$(dirname "${GITEA_APP_INI}") + mkdir -p "${GITEA_APP_INI_DIR}" && chmod 0700 "${GITEA_APP_INI_DIR}" + if [ ! -w "${GITEA_APP_INI_DIR}" ]; then echo "${GITEA_APP_INI_DIR} is not writable"; exit 1; fi # Set INSTALL_LOCK to true only if SECRET_KEY is not empty and # INSTALL_LOCK is empty @@ -34,7 +34,7 @@ if [ ! -f ${GITEA_APP_INI} ]; then ROOT_URL=${ROOT_URL:-""} \ DISABLE_SSH=${DISABLE_SSH:-"false"} \ SSH_PORT=${SSH_PORT:-"2222"} \ - SSH_LISTEN_PORT=${SSH_LISTEN_PORT:-$SSH_PORT} \ + SSH_LISTEN_PORT=${SSH_LISTEN_PORT:-} \ DB_TYPE=${DB_TYPE:-"sqlite3"} \ DB_HOST=${DB_HOST:-"localhost:3306"} \ DB_NAME=${DB_NAME:-"gitea"} \ @@ -44,8 +44,8 @@ if [ ! -f ${GITEA_APP_INI} ]; then DISABLE_REGISTRATION=${DISABLE_REGISTRATION:-"false"} \ REQUIRE_SIGNIN_VIEW=${REQUIRE_SIGNIN_VIEW:-"false"} \ SECRET_KEY=${SECRET_KEY:-""} \ - envsubst < /etc/templates/app.ini > ${GITEA_APP_INI} + envsubst < /etc/templates/app.ini > "${GITEA_APP_INI}" fi # Replace app.ini settings with env variables in the form GITEA__SECTION_NAME__KEY_NAME -environment-to-ini --config ${GITEA_APP_INI} +environment-to-ini --config "${GITEA_APP_INI}" diff --git a/go.mod b/go.mod index 41b5848d7f7..fc57df2ed00 100644 --- a/go.mod +++ b/go.mod @@ -109,12 +109,12 @@ require ( github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc gitlab.com/gitlab-org/api/client-go/v2 v2.26.0 go.yaml.in/yaml/v4 v4.0.0-rc.3 - golang.org/x/crypto v0.51.0 + golang.org/x/crypto v0.52.0 golang.org/x/image v0.40.0 - golang.org/x/net v0.54.0 + golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/text v0.37.0 google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 diff --git a/go.sum b/go.sum index 8e2672bb82a..883c57127a3 100644 --- a/go.sum +++ b/go.sum @@ -793,8 +793,8 @@ 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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= @@ -827,8 +827,8 @@ 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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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= @@ -876,8 +876,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/models/issues/review.go b/models/issues/review.go index 78ef0d20c23..99366222198 100644 --- a/models/issues/review.go +++ b/models/issues/review.go @@ -483,6 +483,14 @@ func SubmitReview(ctx context.Context, doer *user_model.User, issue *Issue, revi if _, err := sess.ID(review.ID).Cols("content, type, official, commit_id, stale").Update(review); err != nil { return nil, nil, err } + + // make sure the leftover review request is cleared, consistent with CreateReview + if reviewType != ReviewTypePending { + if _, err := sess.Where(builder.Eq{"reviewer_id": doer.ID, "issue_id": issue.ID, "type": ReviewTypeRequest}). + Delete(new(Review)); err != nil { + return nil, nil, err + } + } } comm, err := CreateComment(ctx, &CreateCommentOptions{ diff --git a/models/issues/review_test.go b/models/issues/review_test.go index 092d88d1749..a384dbd30fe 100644 --- a/models/issues/review_test.go +++ b/models/issues/review_test.go @@ -303,6 +303,46 @@ func TestDeleteDismissedReview(t *testing.T) { unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID}) } +func TestSubmitReviewClearsStaleReviewRequest(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + assert.NoError(t, issue.LoadRepo(t.Context())) + assert.NoError(t, issue.Repo.LoadOwner(t.Context())) + reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + // the reviewer is requested to review the pull request + requestReview, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{ + Type: issues_model.ReviewTypeRequest, + Issue: issue, + Reviewer: reviewer, + }) + assert.NoError(t, err) + + // the reviewer starts a pending review (e.g. by adding code comments) + pendingReview, err := issues_model.CreateReview(t.Context(), issues_model.CreateReviewOptions{ + Type: issues_model.ReviewTypePending, + Issue: issue, + Reviewer: reviewer, + }) + assert.NoError(t, err) + + // submitting the pending review must clear the leftover review request, + // otherwise the reviewer can no longer be re-requested afterwards + review, _, err := issues_model.SubmitReview(t.Context(), reviewer, issue, issues_model.ReviewTypeComment, "looks good", "", false, nil) + assert.NoError(t, err) + assert.Equal(t, pendingReview.ID, review.ID) + assert.Equal(t, issues_model.ReviewTypeComment, review.Type) + + unittest.AssertNotExistsBean(t, &issues_model.Review{ID: requestReview.ID}) + + // the reviewer can be re-requested afterwards (no-op before the fix) + comment, err := issues_model.AddReviewRequest(t.Context(), issue, reviewer, doer, false) + assert.NoError(t, err) + assert.NotNil(t, comment) +} + func TestAddReviewRequest(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/renovate.json5 b/renovate.json5 index c5d712dc0ab..bf913011fbe 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -18,6 +18,7 @@ "managerFilePatterns": ["/(^|/)Makefile$/"], "matchStrings": [ "[A-Z_]+_PACKAGE\\s*\\?=\\s*(?[^@\\s]+?)(?:/cmd/[^@/\\s]+)?@(?\\S+)\\s+# renovate: datasource=(?\\S+)", + "[A-Z_]+_IMAGE\\s*\\?=\\s*(?[^:\\s]+):(?[^@\\s]+)@(?sha256:[a-f0-9]+)\\s+# renovate: datasource=(?\\S+)", ], }, ], diff --git a/routers/api/v1/repo/mirror.go b/routers/api/v1/repo/mirror.go index ac2d8bba06a..bdca030b765 100644 --- a/routers/api/v1/repo/mirror.go +++ b/routers/api/v1/repo/mirror.go @@ -6,6 +6,7 @@ package repo import ( "errors" "net/http" + "strings" "time" "code.gitea.io/gitea/models/db" @@ -101,6 +102,8 @@ func PushMirrorSync(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" if !setting.Mirror.Enabled { ctx.APIError(http.StatusBadRequest, "Mirror feature is disabled") @@ -112,14 +115,18 @@ func PushMirrorSync(ctx *context.APIContext) { ctx.APIError(http.StatusNotFound, err) return } + + failedPushMirrors := make([]string, 0) for _, mirror := range pushMirrors { ok := mirror_service.SyncPushMirror(ctx, mirror.ID) if !ok { - ctx.APIErrorInternal(errors.New("error occurred when syncing push mirror " + mirror.RemoteName)) - return + failedPushMirrors = append(failedPushMirrors, mirror.RemoteName) } } - + if len(failedPushMirrors) != 0 { + ctx.APIError(http.StatusUnprocessableEntity, "error occurred when syncing push mirrors: "+strings.Join(failedPushMirrors, ", ")) + return + } ctx.Status(http.StatusOK) } diff --git a/routers/api/v1/repo/mirror_test.go b/routers/api/v1/repo/mirror_test.go new file mode 100644 index 00000000000..6cbed49d899 --- /dev/null +++ b/routers/api/v1/repo/mirror_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +// TestPushMirrorSync verifies the endpoint attempts every push mirror instead +// of aborting on the first failure, reporting all failed remotes with a 422. +// Each remote name is not a configured git remote, so SyncPushMirror fails fast +// without any network access. +func TestPushMirrorSync(t *testing.T) { + unittest.PrepareTestEnv(t) + defer test.MockVariableValue(&setting.Mirror.Enabled, true)() + + for _, remoteName := range []string{"broken_remote_1", "broken_remote_2"} { + assert.NoError(t, db.Insert(t.Context(), &repo_model.PushMirror{RepoID: 1, RemoteName: remoteName})) + } + + ctx, resp := contexttest.MockAPIContext(t, "user2/repo1") + contexttest.LoadRepo(t, ctx, 1) + + PushMirrorSync(ctx) + + assert.Equal(t, http.StatusUnprocessableEntity, ctx.Resp.WrittenStatus()) + assert.Contains(t, resp.Body.String(), "broken_remote_1") + assert.Contains(t, resp.Body.String(), "broken_remote_2") +} diff --git a/snap/part-gitea-build.sh b/snap/part-gitea-build.sh index 8388c16e06e..005162488fa 100755 --- a/snap/part-gitea-build.sh +++ b/snap/part-gitea-build.sh @@ -1,7 +1,7 @@ #!/bin/sh set -e -if [ ! -f go.mod -o ! -d snap ]; then +if [ ! -f go.mod ] || [ ! -d snap ]; then echo "This script should be run from the root of the gitea repository" exit 1 fi diff --git a/snap/part-gitea-pull.sh b/snap/part-gitea-pull.sh index 0d117bb4d1d..83fde11a129 100755 --- a/snap/part-gitea-pull.sh +++ b/snap/part-gitea-pull.sh @@ -1,7 +1,7 @@ #!/bin/sh set -e -if [ ! -f go.mod -o ! -d snap ]; then +if [ ! -f go.mod ] || [ ! -d snap ]; then echo "This script should be run from the root of the gitea repository" exit 1 fi diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 312eb25fd8d..d95699a48d6 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -15646,6 +15646,9 @@ }, "404": { "$ref": "#/responses/notFound" + }, + "422": { + "$ref": "#/responses/validationError" } } } diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 04b8bd2d622..642154c5538 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -27446,6 +27446,9 @@ }, "404": { "$ref": "#/components/responses/notFound" + }, + "422": { + "$ref": "#/components/responses/validationError" } }, "summary": "Sync all push mirrored repository", diff --git a/tests/e2e/events.test.ts b/tests/e2e/events.test.ts index c7cfef6e9c2..54b4774a5cc 100644 --- a/tests/e2e/events.test.ts +++ b/tests/e2e/events.test.ts @@ -12,7 +12,7 @@ test.describe('events', () => { // Create repo and login in parallel — repo is needed for the issue, login for the event stream await Promise.all([ - apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(owner)}), + apiCreateRepo(request, {name: repoName, autoInit: false, headers: apiUserHeaders(owner)}), loginUser(page, owner), ]); await page.goto('/'); @@ -36,7 +36,7 @@ test.describe('events', () => { await Promise.all([ loginUser(page, name), (async () => { - await apiCreateRepo(request, {name, headers}); + await apiCreateRepo(request, {name, autoInit: false, headers}); await apiCreateIssue(request, {owner: name, repo: name, title: 'events stopwatch test', headers}); await apiStartStopwatch(request, name, name, 1, {headers}); })(), diff --git a/tests/e2e/fork.test.ts b/tests/e2e/fork.test.ts new file mode 100644 index 00000000000..77ccda1242e --- /dev/null +++ b/tests/e2e/fork.test.ts @@ -0,0 +1,18 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, apiCreateRepo, apiCreateUser, apiUserHeaders, randomString} from './utils.ts'; + +test('fork a repository', async ({page, request}) => { + const upstream = `fork-owner-${randomString(8)}`; + const repoName = `e2e-fork-${randomString(8)}`; + await apiCreateUser(request, upstream); + await Promise.all([ + apiCreateRepo(request, {name: repoName, headers: apiUserHeaders(upstream)}), + login(page), + ]); + await page.goto(`/${upstream}/${repoName}/fork`); + + await page.getByRole('button', {name: 'Fork Repository'}).click(); + await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}$`)); + await expect(page.getByRole('link', {name: `${upstream}/${repoName}`})).toBeVisible(); +}); diff --git a/tests/e2e/issue-comment.test.ts b/tests/e2e/issue-comment.test.ts new file mode 100644 index 00000000000..d3de59ba5f6 --- /dev/null +++ b/tests/e2e/issue-comment.test.ts @@ -0,0 +1,24 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts'; + +test('comment on and close an issue', async ({page, request}) => { + const repoName = `e2e-issue-comment-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await apiCreateRepo(request, {name: repoName, autoInit: false}); + await Promise.all([ + apiCreateIssue(request, {owner, repo: repoName, title: 'Comment test'}), + login(page), + ]); + await page.goto(`/${owner}/${repoName}/issues/1`); + + const body = `e2e-comment-${randomString(8)}`; + await page.getByPlaceholder('Leave a comment').fill(body); + // exact match: the status button reads "Close with Comment" while the box has content, which substring-matches "Comment" + await page.getByRole('button', {name: 'Comment', exact: true}).click(); + await expect(page.locator('.comment-body').filter({hasText: body})).toBeVisible(); + + // posting reloaded the page with an empty box, so the status button now reads "Close Issue" + await page.getByRole('button', {name: 'Close Issue'}).click(); + await expect(page.getByRole('button', {name: 'Reopen Issue'})).toBeVisible(); +}); diff --git a/tests/e2e/issue-project.test.ts b/tests/e2e/issue-project.test.ts index b1fed72a6f9..1595cfadc5a 100644 --- a/tests/e2e/issue-project.test.ts +++ b/tests/e2e/issue-project.test.ts @@ -5,7 +5,7 @@ import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProject, crea test('assign issue to project and change column', async ({page}) => { const repoName = `e2e-issue-project-${randomString(8)}`; const user = env.GITEA_TEST_E2E_USER; - await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName})]); + await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName, autoInit: false})]); await page.goto(`/${user}/${repoName}/projects/new`); await page.locator('input[name="title"]').fill('Kanban Board'); await page.getByRole('button', {name: 'Create Project'}).click(); @@ -33,7 +33,7 @@ test('create a project', async ({page}) => { const projectTitle = 'Test Project'; await login(page); - await apiCreateRepo(page.request, {name: repoName}); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); try { // Navigate to new project page @@ -62,7 +62,7 @@ test('assign issue to multiple projects via sidebar', async ({page}) => { const issueTitle = 'Test issue for multiple projects'; await login(page); - await apiCreateRepo(page.request, {name: repoName}); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); try { // Create two projects via UI @@ -112,7 +112,7 @@ test('create issue with multiple projects pre-selected', async ({page}) => { const issueTitle = 'Issue with multiple projects'; await login(page); - await apiCreateRepo(page.request, {name: repoName}); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); try { // Create two projects via UI @@ -163,7 +163,7 @@ test('filter issues by multiple projects in issue list', async ({page}) => { const project2Title = 'Filter Project B'; await login(page); - await apiCreateRepo(page.request, {name: repoName}); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); try { // Create two projects via UI @@ -229,7 +229,7 @@ test('remove issue from one project keeping others', async ({page}) => { const issueTitle = 'Issue to modify projects'; await login(page); - await apiCreateRepo(page.request, {name: repoName}); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); try { // Create two projects via UI @@ -288,7 +288,7 @@ test('filter issues with no project using project=-1', async ({page}) => { const projectTitle = 'Some Project'; await login(page); - await apiCreateRepo(page.request, {name: repoName}); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); try { // Create a project via UI @@ -349,7 +349,7 @@ test('close project and view in closed projects list', async ({page}) => { const closedProjectTitle = 'Project To Close'; await login(page); - await apiCreateRepo(page.request, {name: repoName}); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); try { // Create two projects via UI @@ -404,7 +404,7 @@ test('select projects on new issue page shows in sidebar', async ({page}) => { const project2Title = 'Project Two'; await login(page); - await apiCreateRepo(page.request, {name: repoName}); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); try { // Create two projects diff --git a/tests/e2e/mermaid.test.ts b/tests/e2e/mermaid.test.ts index b2e066e5510..1523577eaa7 100644 --- a/tests/e2e/mermaid.test.ts +++ b/tests/e2e/mermaid.test.ts @@ -5,7 +5,7 @@ import {apiCreateRepo, apiCreateIssue, assertNoJsError, randomString} from './ut test('mermaid diagram in issue', async ({page, request}) => { const repoName = `e2e-mermaid-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; - await apiCreateRepo(request, {name: repoName}); + await apiCreateRepo(request, {name: repoName, autoInit: false}); const body = '```mermaid\nflowchart LR\n Alpha --> Beta\n Beta --> Gamma\n```\n'; const {index} = await apiCreateIssue(request, {owner, repo: repoName, title: 'mermaid test', body}); await page.goto(`/${owner}/${repoName}/issues/${index}`); diff --git a/tests/e2e/milestone.test.ts b/tests/e2e/milestone.test.ts index 5a688fb1282..106381e64f0 100644 --- a/tests/e2e/milestone.test.ts +++ b/tests/e2e/milestone.test.ts @@ -4,7 +4,7 @@ import {login, apiCreateRepo, randomString} from './utils.ts'; test('create a milestone', async ({page}) => { const repoName = `e2e-milestone-${randomString(8)}`; - await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName})]); + await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName, autoInit: false})]); await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/milestones/new`); await page.getByPlaceholder('Title').fill('Test Milestone'); await page.getByRole('button', {name: 'Create Milestone'}).click(); diff --git a/tests/e2e/pr-create.test.ts b/tests/e2e/pr-create.test.ts new file mode 100644 index 00000000000..592512683fc --- /dev/null +++ b/tests/e2e/pr-create.test.ts @@ -0,0 +1,23 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, apiCreateRepo, apiCreateFile, randomString} from './utils.ts'; + +test('create a pull request from the compare page', async ({page, request}) => { + const repoName = `e2e-pr-create-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await apiCreateRepo(request, {name: repoName}); + await Promise.all([ + apiCreateFile(request, owner, repoName, 'feat.txt', 'feature content\n', {branch: 'main', newBranch: 'feat'}), + login(page), + ]); + // expand=1 renders the PR form directly, skipping the "New Pull Request" toggle click + await page.goto(`/${owner}/${repoName}/compare/main...feat?expand=1`); + + const title = `e2e-pr-${randomString(8)}`; + await page.getByPlaceholder('Title').fill(title); + await page.getByRole('button', {name: 'Create Pull Request'}).click(); + + // commit, not full load: the PR title heading is server-rendered, so the assertion can resolve before the heavy diff/timeline finishes + await page.waitForURL(new RegExp(`/${owner}/${repoName}/pulls/\\d+$`), {waitUntil: 'commit'}); + await expect(page.getByRole('heading', {name: title})).toBeVisible(); +}); diff --git a/tests/e2e/reactions.test.ts b/tests/e2e/reactions.test.ts index 2048b938cf2..a6b61837416 100644 --- a/tests/e2e/reactions.test.ts +++ b/tests/e2e/reactions.test.ts @@ -5,7 +5,7 @@ import {login, apiCreateRepo, apiCreateIssue, randomString} from './utils.ts'; test('toggle issue reactions', async ({page, request}) => { const repoName = `e2e-reactions-${randomString(8)}`; const owner = env.GITEA_TEST_E2E_USER; - await apiCreateRepo(request, {name: repoName}); + await apiCreateRepo(request, {name: repoName, autoInit: false}); await Promise.all([ apiCreateIssue(request, {owner, repo: repoName, title: 'Reaction test'}), login(page), diff --git a/tests/e2e/release.test.ts b/tests/e2e/release.test.ts new file mode 100644 index 00000000000..219a16d2c5f --- /dev/null +++ b/tests/e2e/release.test.ts @@ -0,0 +1,19 @@ +import {env} from 'node:process'; +import {test, expect} from '@playwright/test'; +import {login, apiCreateRepo, randomString} from './utils.ts'; + +test('create a release', async ({page, request}) => { + const repoName = `e2e-release-${randomString(8)}`; + const owner = env.GITEA_TEST_E2E_USER; + await Promise.all([apiCreateRepo(request, {name: repoName}), login(page)]); + await page.goto(`/${owner}/${repoName}/releases/new`); + + const tag = `v1.0.0-${randomString(8)}`; + const title = `e2e-release-${randomString(8)}`; + await page.getByLabel('Tag name').fill(tag); + await page.getByLabel('Release title').fill(title); + await page.getByRole('button', {name: 'Publish Release'}).click(); + + await page.waitForURL(new RegExp(`/${owner}/${repoName}/releases$`)); + await expect(page.locator('.release-list-title')).toContainText(title); +}); diff --git a/tests/e2e/repo-star-watch.test.ts b/tests/e2e/repo-star-watch.test.ts new file mode 100644 index 00000000000..d5f84491651 --- /dev/null +++ b/tests/e2e/repo-star-watch.test.ts @@ -0,0 +1,20 @@ +import {test, expect} from '@playwright/test'; +import {login, apiCreateRepo, apiCreateUser, apiUserHeaders, randomString} from './utils.ts'; + +test('star and watch a repository', async ({page, request}) => { + const owner = `sw-owner-${randomString(8)}`; + const repoName = `e2e-star-watch-${randomString(8)}`; + await apiCreateUser(request, owner); + await Promise.all([ + apiCreateRepo(request, {name: repoName, autoInit: false, headers: apiUserHeaders(owner)}), + login(page), + ]); + await page.goto(`/${owner}/${repoName}`); + + // exact match so "Star"/"Watch" don't also match "Unstar"/"Unwatch" + await page.getByRole('button', {name: 'Star', exact: true}).click(); + await expect(page.getByRole('button', {name: 'Unstar'})).toBeVisible(); + + await page.getByRole('button', {name: 'Watch', exact: true}).click(); + await expect(page.getByRole('button', {name: 'Unwatch'})).toBeVisible(); +}); diff --git a/tools/lint-shell.sh b/tools/lint-shell.sh new file mode 100755 index 00000000000..dac838e8338 --- /dev/null +++ b/tools/lint-shell.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail + +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" +VERSION=$(echo "$SHELLCHECK_IMAGE" | sed -E 's/.*:v([0-9.]+)@.*/\1/') + +if hash shellcheck 2>/dev/null && shellcheck --version | grep -qx "version: $VERSION"; then + exec shellcheck --color=always "$@" +else + exec "$CONTAINER_RUNTIME" run --rm -v "$PWD":/mnt -w /mnt "$SHELLCHECK_IMAGE" --color=always "$@" +fi diff --git a/tools/test-e2e.sh b/tools/test-e2e.sh index d6c053ff1f5..be3e848ee1d 100755 --- a/tools/test-e2e.sh +++ b/tools/test-e2e.sh @@ -71,8 +71,10 @@ if [ "$CMD" = "install" ]; then if [ "$PLAYWRIGHT_MODE" = "local" ]; then # on GitHub Actions VMs, playwright's system deps are pre-installed if [ -z "${GITHUB_ACTIONS:-}" ]; then + # shellcheck disable=SC2086 # flag string pnpm exec playwright install --with-deps chromium firefox ${PLAYWRIGHT_FLAGS:-} else + # shellcheck disable=SC2086 # flag string pnpm exec playwright install chromium firefox ${PLAYWRIGHT_FLAGS:-} fi else diff --git a/web_src/css/repo.css b/web_src/css/repo.css index f3b169aeb91..b47e03b8ce4 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1578,6 +1578,7 @@ tbody.commit-list { #diff-container { display: flex; + gap: var(--page-spacing); } #diff-file-boxes {